diff --git a/README.md b/README.md index 55d89fb..5559af0 100644 --- a/README.md +++ b/README.md @@ -3,28 +3,26 @@ 基于 **Cloudflare Email Workers** 的轻量邮件接收与转发平台。 ```text -Internet → Cloudflare Email Routing → Email Worker → Node → Console → D1/R2 - ├→ Webhook - ├→ API v1 - └→ MCP +Internet → Cloudflare Email Routing → Email Worker → Console → D1/R2 + (catch-all) (authorize/ingest) ├→ Webhook + ├→ API v1 + └→ MCP ``` -- **Console**(控制平面)= 前端(Vite + React + antd v6)+ 管理 API,部署在 Cloudflare Workers,负责 Cloudflare OAuth、账号/域名/邮箱/邮件管理、数据存储与 TTL 清理。 -- **Node**(转发平面)= 纯后端转发节点(Bun),收到什么转发什么,不做业务判断。 -- **Email Worker** = Cloudflare 邮件入口,只把邮件事件安全转发给 Node。 +- **Console**(唯一业务控制中心)= 前端(Vite + React + antd v6)+ 管理 API + 内部邮件 API(`/internal/email/authorize` / `/internal/email/ingest`),部署在 Cloudflare Workers,负责 Cloudflare OAuth、账号/域名/邮箱/邮件管理、数据存储与 TTL 清理。 +- **Email Worker** = 邮件入口 + SMTP reject 执行者:先向 Console authorize 询问是否接收,明确拒绝时 `message.setReject()`,允许时把 raw MIME 提交 ingest;超时/5xx 一律按临时错误,不伪装成邮箱不存在。 -> **什么是「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`。 +> v2.0.0 起 Node 已从邮件架构中移除(旧 `Email Worker → Node → Console` 链路废弃,docs/11)。 +> Email Worker 直连 Console;「账号」= Connect Cloudflare Account(OAuth 自动接入, +> 自动部署账号级 Email Worker 并签发 Worker 凭据)。 ## 本地开发(无需 Cloudflare 账号) ```sh bun install -bun run dev # 一键启动:console + node + email worker + 自动 OAuth(sim)安装 -bun run dev -- --reset # 清空本地数据重新安装 -bun test # 测试套件(协议安全/管线/TTL/Webhook/MCP/OAuth) +bun run dev # 一键启动:console + email worker + 自动 OAuth(sim)接入 +bun run dev -- --reset # 清空本地数据重新接入 +bun test # 测试套件(协议安全/authorize 决策/管线/TTL/Webhook/MCP/OAuth) bun tests/ui-smoke.ts # UI 冒烟(需本机 Playwright chromium) ``` @@ -33,77 +31,93 @@ bun tests/ui-smoke.ts # UI 冒烟(需本机 Playwright chromium) | 服务 | 地址 | | --- | --- | | Console(前端 + API) | http://127.0.0.1:18787 | -| Node | http://127.0.0.1:18788/__health | | Email Worker(本地联调) | http://127.0.0.1:18789/__health | **登录**:默认 `admin / admin`。 -**发测试邮件**(走完整链路 Worker→Node→Console): +**发测试邮件**(走完整两阶段链路 Worker→authorize→ingest): ```sh -SECRET=$(python3 -c "import json;print(json.load(open('node/config.json'))['ingressSecret'])") +COOKIE=$(curl -si -X POST http://127.0.0.1:18787/api/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"username":"admin","password":"admin"}' \ + | grep -i '^set-cookie' | sed 's/^.*wmh_session=\([^;]*\).*/wmh_session=\1/') +ACC=$(curl -s http://127.0.0.1:18787/api/accounts -H "Cookie: $COOKIE" \ + | python3 -c "import sys,json;print(json.load(sys.stdin)['items'][0]['id'])") +SECRET=$(curl -s http://127.0.0.1:18787/api/accounts/$ACC/worker-secret \ + -H "Cookie: $COOKIE" | python3 -c "import sys,json;print(json.load(sys.stdin)['secret'])") + curl -X POST http://127.0.0.1:18789/__email_test \ - -H "X-WmailHub-Ingress-Secret: $SECRET" \ + -H "X-WmailHub-Test-Secret: $SECRET" \ -H "X-WmailHub-Test-From: alice@example.org" \ -H "X-WmailHub-Test-To: hello@example.test" \ --data-binary @tests/fixtures/test-mail.eml +# {"action":"accepted","email_id":"msg_xxx","mailbox_id":"mbx_xxx"} +# 不存在的邮箱:{"action":"rejected","reason":"Mailbox does not exist","code":"MAILBOX_NOT_FOUND"} ``` -`bun run dev` 首次运行会自动:「添加账号」→ sim 模式 Cloudflare OAuth 自动创建账号 → 同步账号下全部域名 + catch-all 邮箱 → 逐域名配置 Email Workers → Node enrollment 换取身份(config.json,0600)→ 心跳上线。全程无需复制任何安装命令。 +`bun run dev` 首次运行会自动:Connect Cloudflare Account(sim 模式 OAuth)→ 同步账号下全部域名 + catch-all 邮箱 → 逐域名配置 Email Workers(部署账号级 Worker + Email Routing + catch-all 规则)→ 签发 WorkerCredential → 启动本地 Email Worker 直连 Console。全程无需复制任何安装命令。 ## 功能 - **用户登录**:用户名密码(PBKDF2),默认 admin/admin,HMAC 签名会话 cookie;密码在「设置」中修改。 -- **账号管理**:「添加账号」直达 Cloudflare OAuth(本地 sim / 真实 OAuth)→ 自动创建账号并同步该账号下全部域名;凭据轮换、Ingress 修改。 -- **域名**:由 OAuth 自动同步(无手工添加);点击域名「设置 Email Workers」= 部署账号级 Email Worker + 开启 Email Routing + catch-all 规则;支持按账号筛选。 +- **账号管理**:「添加账号」直达 Cloudflare OAuth → 自动创建账号并同步该账号下全部域名、部署 Email Worker、签发 Worker 凭据;支持凭据轮换(轮换后已配置域名自动重新部署)。 +- **域名**:由 OAuth 自动同步(无手工添加);点击域名「设置 Email Workers」= 确保账号级 Worker 部署 + 开启 Email Routing + catch-all 规则;支持按账号筛选。 +- **两阶段接收**:authorize(SMTP 决策:accept/reject/temporary_error,拒绝码 DOMAIN_NOT_FOUND / DOMAIN_DISABLED / MAILBOX_NOT_FOUND / MAILBOX_DISABLED / RECEIVING_DISABLED)+ ingest(raw MIME 入库);动态邮箱创建/删除/禁用下一封邮件立即生效。 - **邮件管线**:MIME 解析(postal-mime)、附件、catch-all/精确匹配、大小限额、malformed 降级入库。 - **TTL**:邮件默认保留 1 天(`expires_at`),Cron 定期清理 R2 + DB + orphan,幂等。 - **Webhook(三级)**:配置落在 邮箱 → 域名 → 全局(设置)三级,收信时取第一个命中者转发 `email.received` 事件;支持自定义请求头与 key(key 默认以 `Authorization: Bearer ` 携带,并用于 `X-WmailHub-Signature` 签名);指数退避重试,SSRF 防护。 -- **API v1**:`Authorization: Bearer wmh_...` 访问 `/api/v1/*`。 +- **API v1**:`Authorization: Bearer wmh_...` 访问 `/api/v1/*`(/accounts /domains /mailboxes /emails)。 - **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) +## Worker <-> Console 协议(docs/04) ```text body_hash = SHA256(raw_body) canonical = METHOD \n PATH \n TIMESTAMP \n NONCE \n body_hash -signature = HMAC-SHA256(node_credential_secret, canonical) +signature = HMAC-SHA256(worker_credential_secret, canonical) ``` -请求头:`Authorization: WmailHub-Node `、`X-WmailHub-Timestamp`(±5 分钟)、`X-WmailHub-Nonce`(防重放)、`X-WmailHub-Body-SHA256`、`X-WmailHub-Signature`、`X-WmailHub-Protocol: 1`。 +请求头:`Authorization: WmailHub-Worker `、`X-WmailHub-Timestamp`(±5 分钟)、`X-WmailHub-Nonce`(防重放)、`X-WmailHub-Body-SHA256`、`X-WmailHub-Signature`、`X-WmailHub-Protocol: 1`。 + +内部 API: + +```text +POST /internal/email/authorize { "recipient": "...", "sender": "..." } → accept | reject | temporary_error +POST /internal/email/ingest raw MIME(message/rfc822)+ X-WmailHub-Recipient / X-WmailHub-Sender +``` + +Console 校验:Worker ID、timestamp 窗口、nonce 防重放、body hash、HMAC、credential 状态(撤销立即生效)、Worker 所属账号与收件域名一致。 ## 目录结构 ```text WmailHub/ console/ Console(Workers 入口 src/worker.ts + 本地运行时 src/local/main.ts + 前端 frontend/) - node/ Node 转发节点(Bun) - worker/ Email Worker(Cloudflare 部署入口 + 本地联调 local.ts) + worker/ Email Worker(Cloudflare 部署模板 shared/src/email-worker-template.ts + 本地联调 local.ts) shared/ 共享协议:HMAC 签名 / Key 生成 / 加密 / SSRF 防护 / Email Worker 脚本模板 - migrations/ D1 schema + migrations/ D1 schema(0001 初始 + 0002 三级 Webhook + 0003 邮件链路 v2) tests/ bun test 测试 + UI 冒烟 + fixtures scripts/ dev 启动(前端构建走 vite) - docs/ 设计文档(00~10) + docs/ 设计文档(00~11;11 为邮件架构改造方案) ``` ## 生产部署(Cloudflare Workers) 1. `bunx wrangler d1 create wmailhub` → 将 `database_id` 填入 `console/wrangler.jsonc` 2. `bunx wrangler r2 bucket create wmailhub` -3. `bunx wrangler d1 execute wmailhub --remote --file=./migrations/0001_init.sql` -4. `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 地址」填其公网地址 +3. 依次执行 `bunx wrangler d1 execute wmailhub --remote --file=./migrations/0001_init.sql`、`0002_accounts_webhooks.sql`、`0003_mail_link_v2.sql` +4. `cd console && bunx wrangler deploy`(前端先 `bun run build`);`PUBLIC_BASE_URL` 即 Email Worker 注入的 CONSOLE_URL +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 模式(仅供本地) -Cron(`*/1 * * * *`)驱动 TTL 清理与 Webhook 重试;Email Worker 由 Console provisioning 自动上传,无需手工部署。 +Cron(`*/1 * * * *`)驱动 TTL 清理与 Webhook 重试;Email Worker 由 Console provisioning 自动上传(无需手工部署),catch-all 规则在「域名 → 设置 Email Workers」时自动配置。 ## 安全设计(docs/08) -- OAuth token、Node credential、ingress secret 全部 AES-256-GCM 加密存储;凭据撤销即时生效。 -- Node→Console 强制 HTTPS + HMAC 签名 + timestamp 窗口 + nonce 防重放 + body hash 防篡改。 -- Worker 只持有 Node ingress secret,不持有 Console 凭据。 +- OAuth token、Worker credential、Webhook key 全部 AES-256-GCM 加密存储;凭据撤销/轮换即时生效。 +- Worker→Console 强制 HTTPS + HMAC 签名 + timestamp 窗口 + nonce 防重放 + body hash 防篡改 + 账号归属校验。 +- Worker 只持有自身凭据,不持有 Cloudflare OAuth 凭据;Console 拒绝文案不泄露内部信息。 - Webhook endpoint 防 SSRF(私网/loopback/metadata/非法 scheme;本地联调需显式 `WMAILHUB_ALLOW_PRIVATE_WEBHOOKS=1`)。 - 会话 cookie `SameSite=Strict` + 跨域 Origin 校验;日志不落 secret 与邮件正文。 diff --git a/console/frontend/src/App.tsx b/console/frontend/src/App.tsx index 707babe..5265a11 100644 --- a/console/frontend/src/App.tsx +++ b/console/frontend/src/App.tsx @@ -28,7 +28,7 @@ const MENU_ITEMS = [ /** # 成员变量: 路由 -> 页面标题/副标题 **/ const PAGE_META: Record = { "/": { title: "概览", subtitle: "账号、域名与邮件总览" }, - "/accounts": { title: "账号", subtitle: "通过 Cloudflare OAuth 接入的 Cloudflare 账号" }, + "/accounts": { title: "账号", subtitle: "通过 Cloudflare OAuth 接入的 Cloudflare 账号(Email Worker 直连)" }, "/domains": { title: "域名", subtitle: "OAuth 自动同步的收件域名与 Email Workers" }, "/mailboxes": { title: "邮箱", subtitle: "收件地址、保留策略与邮箱级 Webhook" }, "/emails": { title: "邮件", subtitle: "已接收邮件(默认保留 1 天)" }, @@ -85,7 +85,7 @@ export function App() {
{meta.subtitle}
- + -
- 默认 Node Ingress 地址 - - 「添加账号」创建的账号默认使用的 Node Ingress 地址:即 Email Worker 收到邮件后转发到的 Node 接收入口 - (Node 再签名转发给 Console)。生产环境填 Node 的公网 HTTPS 地址。 - - - - - - - -
-
void savePassword()}> 修改密码 diff --git a/console/frontend/src/pages/Accounts.tsx b/console/frontend/src/pages/Accounts.tsx index cd9efbc..03aa378 100644 --- a/console/frontend/src/pages/Accounts.tsx +++ b/console/frontend/src/pages/Accounts.tsx @@ -1,4 +1,4 @@ -/** 账号页(原节点):添加账号直达 Cloudflare OAuth,授权后自动同步域名;搜索、详情、凭据管理 */ +/** 账号页:添加账号直达 Cloudflare OAuth,授权后自动接入并同步域名;搜索、详情、Worker 凭据管理 */ import { useCallback, useEffect, useMemo, useState } from "react"; import { useNavigate, useSearchParams } from "react-router-dom"; import { @@ -17,7 +17,6 @@ import { PlusOutlined, ReloadOutlined, SearchOutlined } from "@ant-design/icons" import { deleteAccount, getAccount, - getAccountIngressSecret, listAccounts, oauthMode, oauthStart, @@ -31,17 +30,16 @@ import { const STATUS_COLOR: Record = { pending_oauth: "orange", provisioning: "processing", - online: "success", - offline: "default", + active: "success", error: "error", }; -/** # 成员变量: 安装步骤中文标签(与后端 PROVISION_STEPS 对应) **/ +/** # 成员变量: 接入步骤中文标签(与后端 PROVISION_STEPS 对应) **/ const STEP_LABEL: Record = { oauth: "Cloudflare OAuth 授权", - identity: "Node identity 与 credential", discover: "Account / Zone 发现", domains: "域名与 catch-all 邮箱同步", + worker: "Email Worker 部署与 Worker 凭据", }; /** # 函数 @@ -92,12 +90,11 @@ export function AccountsPage() { [items, q], ); - /** 发起 OAuth:real 模式跳 Cloudflare;sim 模式跳本地模拟授权页。 - * accountId 缺省 = 服务端自动创建账号。 */ - const startOAuth = async (accountId?: string) => { + /** 发起 OAuth:real 模式跳 Cloudflare;sim 模式跳本地模拟授权页 */ + const startOAuth = async () => { try { const mode = (await oauthMode()).mode; - const { url } = await oauthStart(accountId); + const { url } = await oauthStart(); if (mode === "cloudflare") window.location.href = url; else navigate(url); // /oauth/sim/authorize?state=... } catch (err) { @@ -122,7 +119,8 @@ export function AccountsPage() { onChange={(e) => setQ(e.target.value)} /> - 点击「添加账号」完成 Cloudflare OAuth,自动同步账号下全部域名;在域名列表逐域名开启 Email Workers。 + 点击「添加账号」完成 Cloudflare OAuth;Console 自动同步域名、部署 Email Worker + 并签发 Worker 凭据(Email Worker 直连 Console)。
@@ -149,29 +147,23 @@ export function AccountsPage() { render: (v: string) => {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", + title: "Email Worker", + dataIndex: "worker_id", + ellipsis: true, + render: (v: string | null) => (v ? {v} : 未部署), + }, + { + title: "接入时间", + dataIndex: "created_at", width: 170, - render: (v: string | null) => (v ? new Date(v).toLocaleString() : "-"), + render: (v: string) => new Date(v).toLocaleString(), }, { title: "操作", - width: 150, + width: 100, render: (_: unknown, row: AccountRow) => ( - + + Worker ID:{detail.account.worker_id ?? "-"} + {detail.credential ? ` · 凭据 ****${detail.credential.secret_last4}(有效)` : ""} + + + Email Worker 以该凭据直连 Console(/internal/email/authorize 与 /ingest);轮换后已配置的域名会自动重新部署。 + - - - enrollment 有效期至 {detail.account.enrollment_expires_at ?? "-"} - diff --git a/console/frontend/src/pages/Dashboard.tsx b/console/frontend/src/pages/Dashboard.tsx index c2cb53b..0cbc07a 100644 --- a/console/frontend/src/pages/Dashboard.tsx +++ b/console/frontend/src/pages/Dashboard.tsx @@ -49,7 +49,7 @@ export function DashboardPage() { - + diff --git a/console/frontend/src/pages/Domains.tsx b/console/frontend/src/pages/Domains.tsx index 729e54f..cecc2a9 100644 --- a/console/frontend/src/pages/Domains.tsx +++ b/console/frontend/src/pages/Domains.tsx @@ -58,7 +58,7 @@ export function DomainsPage() { () => items.filter( (row) => - (!accountId || row.node_id === accountId) && + (!accountId || row.account_id === accountId) && row.name.toLowerCase().includes(q.trim().toLowerCase()), ), [items, q, accountId], @@ -110,7 +110,7 @@ export function DomainsPage() { onRow={(row) => ({ onClick: () => setEditing(row), style: { cursor: "pointer" } })} columns={[ { title: "域名", dataIndex: "name", render: (v: string) => {v} }, - { title: "账号", dataIndex: "node_name", width: 170, ellipsis: true, render: (v: string | null) => v ?? "-" }, + { title: "账号", dataIndex: "account_name", width: 170, ellipsis: true, render: (v: string | null) => v ?? "-" }, { title: "状态", dataIndex: "status", width: 170, render: (_: unknown, row: DomainRow) => }, { title: "Email Worker", @@ -174,7 +174,7 @@ export function DomainsPage() { {editing.name} - {editing.node_name ?? "-"} + {editing.account_name ?? "-"} ) : ( diff --git a/console/frontend/src/pages/OAuthSim.tsx b/console/frontend/src/pages/OAuthSim.tsx index 97bd196..42bfdd9 100644 --- a/console/frontend/src/pages/OAuthSim.tsx +++ b/console/frontend/src/pages/OAuthSim.tsx @@ -20,7 +20,7 @@ export function OAuthSimPage() { const res = await oauthSimApprove(state); if (res.ok) { message.success("Cloudflare 授权与自动安装完成"); - navigate(`/accounts?provision=${encodeURIComponent(res.node_id)}`, { replace: true }); + navigate(`/accounts?provision=${encodeURIComponent(res.account_id)}`, { replace: true }); } else { message.error(res.error ?? "安装失败"); navigate("/accounts", { replace: true }); diff --git a/console/src/app.ts b/console/src/app.ts index b5853bb..4daf452 100644 --- a/console/src/app.ts +++ b/console/src/app.ts @@ -5,11 +5,11 @@ * /api/health 健康检查 * /api/auth/* 用户登录/登出/会话(cookie) * /api/accounts|domains|mailboxes|emails 管理资源 CRUD(会话保护) - * /api/settings/* 设置:全局 Webhook / 默认 Ingress / 改密码(会话保护) + * /api/settings/* 设置:全局 Webhook / 改密码(会话保护) * /api/keys/* API/MCP Key 管理(会话保护;设置模态窗口内使用) * /api/oauth/* Cloudflare OAuth(会话保护) * /api/admin/* 版本/统计/Cron(会话保护) - * /api/internal/* Node 内部协议(HMAC 签名) + * /internal/email/* Email Worker 内部协议:authorize / ingest(HMAC 签名,docs/11) * /api/v1/mcp MCP JSON-RPC(Bearer MCP Key wmh_) * /api/v1/* 外部 API(Bearer API Key wmh_) * /* 前端 SPA 静态资源 @@ -52,7 +52,7 @@ export function createApp( app.get("/api/health", (c) => c.json({ ok: true, name: APP_NAME, version: APP_VERSION })); app.route("/api/auth", authRoutes()); - app.route("/api/internal", internalRoutes()); + app.route("/internal", internalRoutes()); app.route("/api/oauth", oauthRoutes()); app.route("/api/settings", settingsRoutes()); app.route("/api/keys", keyRoutes()); diff --git a/console/src/db.ts b/console/src/db.ts index 8aacff0..14c6355 100644 --- a/console/src/db.ts +++ b/console/src/db.ts @@ -49,7 +49,7 @@ export async function getMasterSecret(env: Env): Promise { } /** # 函数 - * 获取 AES 加密密钥(OAuth token、node credential secret、ingress secret 加密存储) + * 获取 AES 加密密钥(OAuth token、worker credential secret、webhook key 加密存储) */ export async function getAesKey(env: Env): Promise { const master = await getMasterSecret(env); diff --git a/console/src/env.ts b/console/src/env.ts index 6fefb63..87c06ef 100644 --- a/console/src/env.ts +++ b/console/src/env.ts @@ -78,8 +78,10 @@ export interface AppVars { /** 已认证 API/MCP Key 信息 **/ apiKeyKind?: "api" | "mcp"; apiKeyId?: string; - /** internal 路由中已读出的原始请求体(nodeAuth 中间件缓存) **/ + /** internal 路由中已读出的原始请求体(workerAuth 中间件缓存) **/ rawBody?: Uint8Array; - /** internal 路由中通过校验的 credential id **/ - nodeCredentialId?: string; + /** internal 路由中通过校验的 worker credential id **/ + workerCredentialId?: string; + /** internal 路由中 credential 所属的 Console 账号 id **/ + workerAccountId?: string; } diff --git a/console/src/routes/admin.ts b/console/src/routes/admin.ts index cca9f85..eedb0ad 100644 --- a/console/src/routes/admin.ts +++ b/console/src/routes/admin.ts @@ -28,8 +28,8 @@ export function adminRoutes(): App { (await c.env.DB.prepare(sql).first<{ n: number }>())?.n ?? 0; const now = nowIso(); const stats = { - 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'`), + accounts_total: await count(`SELECT COUNT(*) AS n FROM cloudflare_accounts WHERE status != 'revoked'`), + accounts_active: await count(`SELECT COUNT(*) AS n FROM cloudflare_accounts WHERE status = 'active'`), 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"), @@ -42,6 +42,7 @@ export function adminRoutes(): App { `SELECT COUNT(*) AS n FROM webhook_deliveries WHERE status IN ('pending','failed')`, ), oauth_connections: await count("SELECT COUNT(*) AS n FROM oauth_connections"), + worker_credentials: await count("SELECT COUNT(*) AS n FROM worker_credentials WHERE status = 'active'"), }; const recent = await c.env.DB.prepare( `SELECT id, subject, from_address, received_at, size FROM emails diff --git a/console/src/routes/apiv1.ts b/console/src/routes/apiv1.ts index 0bc79af..a732942 100644 --- a/console/src/routes/apiv1.ts +++ b/console/src/routes/apiv1.ts @@ -18,11 +18,11 @@ export function apiV1Routes(): App { const app = new Hono<{ Bindings: Env; Variables: AppVars }>(); app.use("*", requireApiKey("api")); - /** 节点列表 */ - app.get("/nodes", async (c) => { + /** 账号列表 */ + app.get("/accounts", async (c) => { const rows = await c.env.DB.prepare( - `SELECT id, name, cloudflare_account_id, status, version, last_seen_at, created_at - FROM nodes WHERE status != 'revoked' ORDER BY created_at DESC`, + `SELECT id, name, cloudflare_account_id, worker_id, status, created_at + FROM cloudflare_accounts WHERE status != 'revoked' ORDER BY created_at DESC`, ).all(); return c.json({ items: rows.results }); }); @@ -30,7 +30,7 @@ export function apiV1Routes(): App { /** 域名列表 */ app.get("/domains", async (c) => { const rows = await c.env.DB.prepare( - "SELECT id, name, status, node_id, created_at FROM domains ORDER BY created_at DESC", + "SELECT id, name, status, account_id, created_at FROM domains ORDER BY created_at DESC", ).all(); return c.json({ items: rows.results }); }); diff --git a/console/src/routes/internal.ts b/console/src/routes/internal.ts index 2b032db..bde791d 100644 --- a/console/src/routes/internal.ts +++ b/console/src/routes/internal.ts @@ -1,21 +1,19 @@ /** - * /api/internal/* :Node <-> Console 内部协议路由(docs/04) + * /internal/email/* :Email Worker <-> Console 内部协议(docs/11 §7/§8/§9) * - * POST /api/internal/nodes/{node_id}/emails 邮件转发(message/rfc822) - * POST /api/internal/nodes/{node_id}/heartbeat 心跳 - * GET /api/internal/nodes/{node_id}/config 配置拉取 - * POST /api/internal/enroll bootstrap enrollment + * POST /internal/email/authorize SMTP 阶段决策:accept | reject | temporary_error + * POST /internal/email/ingest 原始 MIME 入库(D1 + R2 + Webhook),仅 accept 后调用 * - * 前三者必须通过 HMAC 签名校验 + nonce 防重放 + credential 状态检查; - * enroll 用一次性 enrollment token(节点尚无 secret)。 + * 两者都必须通过 HMAC 签名校验 + nonce 防重放 + credential/账号状态检查(docs/11 §8): + * Console 校验 Worker ID、timestamp、nonce、body hash、HMAC、credential 状态, + * 并确认请求 Worker 所属 Cloudflare 账号与收件域名一致。 */ import { Hono } from "hono"; import type { Context, Next } from "hono"; import type { Env, AppVars } from "../env.ts"; -import { verifyNodeRequest } from "../../../shared/src/index.ts"; -import { ingestEmail, IngestError } from "../services/email_ingest.ts"; -import { enrollNode } from "../services/provision.ts"; +import { verifyWorkerRequest } from "../../../shared/src/index.ts"; +import { ingestEmail, IngestError, resolveMailbox } from "../services/email_ingest.ts"; import { decryptSecret } from "../db.ts"; import { nowIso } from "../util.ts"; @@ -25,22 +23,44 @@ type Ctx = Context<{ Bindings: Env; Variables: AppVars }>; /** # 成员变量: nonce 防重放窗口(与 timestamp 窗口一致) **/ const NONCE_TTL_SECONDS = 600; -/** # 函数 - * HMAC 校验中间件:验签、时间窗、body hash、nonce 唯一性、credential 状态。 - * 原始请求体缓存到 c.var.rawBody 供路由处理器复用。 - */ -async function nodeAuth(c: Ctx, next: Next) { - const nodeId = c.req.param("node_id"); - const cred = await c.env.DB.prepare( - "SELECT id, secret_enc, status, revoked_at FROM node_credentials WHERE node_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1", - ) - .bind(nodeId) - .first<{ id: string; secret_enc: string; status: string; revoked_at: string | null }>(); - if (!cred) return c.json({ error: "unknown node or credential revoked" }, 401); +/** # 成员变量: reject 状态码与原因(docs/11 §7;reason 不暴露内部信息) **/ +const REJECT_REASONS: Record = { + DOMAIN_NOT_FOUND: "Mailbox does not exist", + DOMAIN_DISABLED: "Receiving for this domain is not enabled", + MAILBOX_NOT_FOUND: "Mailbox does not exist", + MAILBOX_DISABLED: "Mailbox is disabled", + RECEIVING_DISABLED: "Receiving is disabled", +}; +/** # 成员变量: authorize 请求体 **/ +interface AuthorizeBody { + recipient?: string; + sender?: string; + message_id?: string; +} + +/** # 成员变量: 决策结果 **/ +export type EmailDecision = + | { decision: "accept" } + | { decision: "reject"; code: string; reason: string }; + +/** # 函数 + * HMAC 校验中间件:验签、时间窗、body hash、nonce 唯一性、credential/账号状态。 + * 原始请求体缓存到 c.var.rawBody 供路由处理器复用;workerAccountId 记录归属账号。 + */ +async function workerAuth(c: Ctx, next: Next) { const raw = new Uint8Array(await c.req.arrayBuffer()); + const cred = await c.env.DB.prepare( + `SELECT wc.id, wc.secret_enc, wc.cloudflare_account_id, ca.status AS account_status + FROM worker_credentials wc JOIN cloudflare_accounts ca ON ca.id = wc.cloudflare_account_id + WHERE wc.worker_id = ? AND wc.status = 'active' ORDER BY wc.created_at DESC LIMIT 1`, + ) + .bind((c.req.header("Authorization") ?? "").replace(/^WmailHub-Worker\s+/, "").trim() || "\u0000") + .first<{ id: string; secret_enc: string; cloudflare_account_id: string; account_status: string }>(); + if (!cred) return c.json({ error: "unknown worker or credential revoked" }, 401); + const secret = await decryptSecret(c.env, cred.secret_enc); - const verdict = await verifyNodeRequest({ + const verdict = await verifyWorkerRequest({ secret, method: c.req.method, url: c.req.url, @@ -51,10 +71,10 @@ async function nodeAuth(c: Ctx, next: Next) { // nonce 防重放:主键冲突即拒绝 try { - await c.env.DB.prepare("INSERT INTO nonces (nonce, node_id, expires_at) VALUES (?, ?, ?)") + await c.env.DB.prepare("INSERT INTO nonces (nonce, account_id, expires_at) VALUES (?, ?, ?)") .bind( verdict.nonce, - nodeId, + cred.cloudflare_account_id, new Date((verdict.timestamp + NONCE_TTL_SECONDS) * 1000).toISOString(), ) .run(); @@ -62,54 +82,122 @@ async function nodeAuth(c: Ctx, next: Next) { return c.json({ error: "nonce replay detected" }, 401); } - // 凭据被撤销则在验签后仍然拒绝(撤销立即生效) - if (cred.revoked_at) return c.json({ error: "credential revoked" }, 401); + // 撤销立即生效:credential/账号状态在验签后复查 + const fresh = await c.env.DB.prepare( + `SELECT wc.status AS cred_status, ca.status AS account_status + FROM worker_credentials wc JOIN cloudflare_accounts ca ON ca.id = wc.cloudflare_account_id + WHERE wc.id = ?`, + ) + .bind(cred.id) + .first<{ cred_status: string; account_status: string }>(); + if (!fresh || fresh.cred_status !== "active" || fresh.account_status === "revoked") { + return c.json({ error: "credential revoked" }, 401); + } c.set("rawBody", raw); - c.set("nodeCredentialId", cred.id); + c.set("workerCredentialId", cred.id); + c.set("workerAccountId", cred.cloudflare_account_id); await next(); } /** # 函数 - * 装配 internal 路由 + * 收件人决策逻辑(docs/11 §7):域名归属 -> 域名状态 -> 邮箱精确/ catch-all -> 邮箱状态。 + * 返回明确的 decision,绝不把 5xx/超时解释成邮箱不存在(那由 Worker 层处理)。 + */ +export async function decideRecipient( + env: Env, + accountId: string, + recipient: string, +): Promise { + const addr = recipient.trim().toLowerCase(); + const at = addr.lastIndexOf("@"); + if (at <= 0 || at === addr.length - 1) { + return { decision: "reject", code: "MAILBOX_NOT_FOUND", reason: REJECT_REASONS.MAILBOX_NOT_FOUND }; + } + const domainName = addr.slice(at + 1); + const domain = await env.DB.prepare( + "SELECT id, status FROM domains WHERE name = ? AND account_id = ?", + ) + .bind(domainName, accountId) + .first<{ id: string; status: string }>(); + if (!domain) { + return { decision: "reject", code: "DOMAIN_NOT_FOUND", reason: REJECT_REASONS.DOMAIN_NOT_FOUND }; + } + if (domain.status !== "active") { + return { decision: "reject", code: "DOMAIN_DISABLED", reason: REJECT_REASONS.DOMAIN_DISABLED }; + } + + // 邮箱:精确匹配优先,其次 catch-all;disabled 一律拒绝 + const exact = await env.DB.prepare( + "SELECT id, status FROM mailboxes WHERE address = ? AND domain_id = ?", + ) + .bind(addr, domain.id) + .first<{ id: string; status: string }>(); + if (exact) { + if (exact.status !== "active") { + return { decision: "reject", code: "MAILBOX_DISABLED", reason: REJECT_REASONS.MAILBOX_DISABLED }; + } + return { decision: "accept" }; + } + const catchAll = await env.DB.prepare( + "SELECT id, status FROM mailboxes WHERE domain_id = ? AND is_catch_all = 1 LIMIT 1", + ) + .bind(domain.id) + .first<{ id: string; status: string }>(); + if (catchAll) { + if (catchAll.status !== "active") { + return { decision: "reject", code: "MAILBOX_DISABLED", reason: REJECT_REASONS.MAILBOX_DISABLED }; + } + return { decision: "accept" }; + } + return { decision: "reject", code: "MAILBOX_NOT_FOUND", reason: REJECT_REASONS.MAILBOX_NOT_FOUND }; +} + +/** # 函数 + * 装配 Email Worker 内部路由 */ export function internalRoutes(): App { const app = new Hono<{ Bindings: Env; Variables: AppVars }>(); - /** Enrollment:enrollment token 换取 Node 身份(凭证明文仅返回一次) */ - app.post("/enroll", async (c) => { - let token = ""; + /** 第一阶段:SMTP 决策(不保存邮件) */ + app.post("/email/authorize", workerAuth, async (c) => { + const accountId = c.get("workerAccountId")!; + let body: AuthorizeBody; try { - const body = (await c.req.json()) as { enrollment_token?: string }; - token = body.enrollment_token ?? ""; + body = JSON.parse(new TextDecoder().decode(c.get("rawBody") ?? new Uint8Array())) as AuthorizeBody; } catch { return c.json({ error: "invalid body" }, 400); } - if (!token.startsWith("wenr_")) return c.json({ error: "invalid enrollment token" }, 401); - const result = await enrollNode(c.env, token); - if (!result) return c.json({ error: "invalid or expired enrollment token" }, 401); - return c.json({ - ok: true, - node_id: result.node_id, - credential_id: result.credential_id, - credential_secret: result.credential_secret, - ingress_secret: result.ingress_secret, - }); + const recipient = (body.recipient ?? "").trim(); + if (!recipient) return c.json({ error: "recipient required" }, 400); + const verdict = await decideRecipient(c.env, accountId, recipient); + // 未过期时记录 message_id 便于追踪(仅日志,不入库) + if (body.message_id) { + console.log( + JSON.stringify({ t: nowIso(), msg: "email.authorize", decision: verdict.decision, recipient, message_id: body.message_id }), + ); + } + return c.json(verdict); }); - /** 邮件转发入口:message/rfc822 原文入库 */ - app.post("/nodes/:node_id/emails", nodeAuth, async (c) => { - // nodeAuth 已确保节点存在,param 必有值 - const nodeId = c.req.param("node_id")!; + /** 第二阶段:raw MIME 入库(message/rfc822;收件人经信封头传递) */ + app.post("/email/ingest", workerAuth, async (c) => { + const accountId = c.get("workerAccountId")!; + const recipient = (c.req.header("X-WmailHub-Recipient") ?? "").trim(); + const sender = (c.req.header("X-WmailHub-Sender") ?? "").trim(); + if (!recipient) return c.json({ error: "missing X-WmailHub-Recipient" }, 400); + + // 动态邮箱:authorize 与 ingest 之间状态可能变化,再验一次收件人 + const verdict = await decideRecipient(c.env, accountId, recipient); + if (verdict.decision === "reject") { + return c.json({ error: "recipient no longer accepted", code: verdict.code }, 409); + } + + // 邮箱关联兜底:authorize 已确认可接收,此处解析 mailbox(精确/catch-all) + const mailboxId = await resolveMailbox(c.env, [recipient]); const raw = c.get("rawBody") ?? new Uint8Array(); - const mailFrom = c.req.header("X-WmailHub-Mail-From") ?? ""; - const rcptTo = c.req.header("X-WmailHub-Rcpt-To") ?? ""; try { - const result = await ingestEmail(c.env, { raw, mailFrom, rcptTo, nodeId }); - // 更新节点可见性 - await c.env.DB.prepare("UPDATE nodes SET last_seen_at = ?, updated_at = ? WHERE id = ?") - .bind(nowIso(), nowIso(), nodeId) - .run(); + const result = await ingestEmail(c.env, { raw, mailFrom: sender, rcptTo: recipient, accountId }); return c.json( { ok: true, id: result.id, mailbox_id: result.mailboxId, expires_at: result.expiresAt }, 202, @@ -120,33 +208,5 @@ export function internalRoutes(): App { } }); - /** 心跳:更新 last_seen_at / version / status=online */ - app.post("/nodes/:node_id/heartbeat", nodeAuth, async (c) => { - const nodeId = c.req.param("node_id"); - let version: string | null = null; - try { - const body = c.get("rawBody") ?? new Uint8Array(); - const parsed = JSON.parse(new TextDecoder().decode(body)) as { version?: string }; - version = parsed.version ?? null; - } catch { - // 空 body 合法 - } - await c.env.DB.prepare( - "UPDATE nodes SET last_seen_at = ?, status = 'online', version = COALESCE(?, version), updated_at = ? WHERE id = ?", - ) - .bind(nowIso(), version, nowIso(), nodeId) - .run(); - return c.json({ ok: true }); - }); - - /** 配置拉取:协议版本与默认保留时长 */ - app.get("/nodes/:node_id/config", nodeAuth, (c) => { - return c.json({ - protocol_version: 1, - default_retention_seconds: 86_400, - server_time: nowIso(), - }); - }); - return app; } diff --git a/console/src/routes/oauth.ts b/console/src/routes/oauth.ts index 91ae920..0ef612c 100644 --- a/console/src/routes/oauth.ts +++ b/console/src/routes/oauth.ts @@ -1,11 +1,14 @@ /** - * Cloudflare OAuth 路由(docs/02): - * GET /api/oauth/cloudflare/start?node_id= 生成 state,返回授权跳转 URL - * GET /api/oauth/cloudflare/callback 授权回调:state 校验、token 兑换、自动安装 + * Cloudflare OAuth 路由(docs/02、docs/11 §14): + * GET /api/oauth/cloudflare/start 生成 state,返回授权跳转 URL(自动创建账号) + * GET /api/oauth/cloudflare/callback 授权回调:state 校验、token 兑换、自动接入 * POST /api/oauth/cloudflare/sim/approve sim 模式模拟用户授权(仅本地) * GET /api/oauth/connections 已建立的 OAuth 连接 * - * 安全:state 一次性(用后即删)、5 分钟过期、绑定 node_id;token AES-GCM 加密入库, + * 流程(Connect Cloudflare Account):Console -> Cloudflare OAuth -> 用户授权 -> + * Console 保存 Account + 域名同步 + 部署 Email Worker + 配置 Worker Secret;用户无需安装命令。 + * + * 安全:state 一次性(用后即删)、5 分钟过期、绑定账号;token AES-GCM 加密入库, * 不进前端与日志;Account ownership 以 token 实际可访问的 accounts 为准。 */ @@ -14,8 +17,8 @@ import type { Env, AppVars } from "../env.ts"; import { requireSession } from "../middleware.ts"; import { ids } from "../../../shared/src/index.ts"; import { CF_OAUTH_AUTHORIZE_URL, CF_OAUTH_SCOPES, getCFApi } from "../services/cfapi.ts"; -import { provisionNode, createNode } from "../services/provision.ts"; -import { getSetting, encryptConnectionToken } from "../db.ts"; +import { provisionAccount, createAccount } from "../services/provision.ts"; +import { encryptConnectionToken } from "../db.ts"; import { nowIso, readJson } from "../util.ts"; type App = Hono<{ Bindings: Env; Variables: AppVars }>; @@ -23,9 +26,6 @@ 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 */ @@ -40,7 +40,7 @@ export function oauthRoutes(): App { const app = new Hono<{ Bindings: Env; Variables: AppVars }>(); app.use("*", requireSession); - /** 授权模式与入口信息(前端 Add Node 向导用) */ + /** 授权模式与入口信息(前端 Connect Cloudflare Account 向导用) */ app.get("/mode", (c) => { return c.json({ mode: isRealOAuth(c.env) ? "cloudflare" : "sim", @@ -48,30 +48,17 @@ export function oauthRoutes(): App { }); }); - /** 开始授权:node_id 缺省时自动创建账号;创建 state 并返回跳转 URL */ + /** 开始授权:每次自动创建账号记录;创建 state 并返回跳转 URL */ app.get("/cloudflare/start", async (c) => { - 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 created = await createAccount(c.env, { + name: `Cloudflare 账号 ${new Date().toISOString().slice(0, 10)}`, + }); const state = "cfstate_" + ids.oauthState(); await c.env.DB.prepare( - "INSERT INTO oauth_states (state, node_id, created_at, expires_at) VALUES (?, ?, ?, ?)", + "INSERT INTO oauth_states (state, account_id, created_at, expires_at) VALUES (?, ?, ?, ?)", ) - .bind(state, nodeId, nowIso(), new Date(Date.now() + STATE_TTL_SECONDS * 1000).toISOString()) + .bind(state, created.accountId, nowIso(), new Date(Date.now() + STATE_TTL_SECONDS * 1000).toISOString()) .run(); if (isRealOAuth(c.env)) { @@ -89,7 +76,7 @@ export function oauthRoutes(): App { return c.json({ url: `/oauth/sim/authorize?state=${encodeURIComponent(state)}`, state }); }); - /** 授权回调(浏览器重垒):校验 state -> 兑换 token -> 自动安装 -> 跳回控制台 */ + /** 授权回调(浏览器重定向):校验 state -> 兑换 token -> 自动接入 -> 跳回控制台 */ app.get("/cloudflare/callback", async (c) => { const url = new URL(c.req.url); const code = url.searchParams.get("code") ?? ""; @@ -105,8 +92,8 @@ export function oauthRoutes(): App { const token = await cf.exchangeToken(code, redirectUri); // 加密入库(不进前端/日志) await saveConnection(c.env, token.accessToken, token.expiresIn); - const result = await provisionNode(c.env, { nodeId: verdict.nodeId, accessToken: token.accessToken }); - const target = `/accounts?provision=${encodeURIComponent(verdict.nodeId)}&ok=${result.ok ? 1 : 0}`; + const result = await provisionAccount(c.env, { accountId: verdict.accountId, accessToken: token.accessToken }); + const target = `/accounts?provision=${encodeURIComponent(verdict.accountId)}&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); @@ -121,8 +108,8 @@ export function oauthRoutes(): App { const cf = getCFApi(c.env, c.env.DB); const token = await cf.exchangeToken("sim-code", "urn:unused"); await saveConnection(c.env, token.accessToken, token.expiresIn); - const result = await provisionNode(c.env, { nodeId: verdict.nodeId, accessToken: token.accessToken }); - return c.json({ ok: result.ok, error: result.error, node_id: verdict.nodeId }); + const result = await provisionAccount(c.env, { accountId: verdict.accountId, accessToken: token.accessToken }); + return c.json({ ok: result.ok, error: result.error, account_id: verdict.accountId }); }); /** 已有连接列表(不含 token) */ @@ -137,20 +124,20 @@ export function oauthRoutes(): App { } /** # 函数 - * 消费一次性 state:存在且未过期则删除并返回 node_id,否则 null(CSRF 防护核心) + * 消费一次性 state:存在且未过期则删除并返回 account_id,否则 null(CSRF 防护核心) */ async function consumeState( env: Env, state: string, -): Promise<{ nodeId: string } | null> { +): Promise<{ accountId: string } | null> { if (!state) return null; - const row = await env.DB.prepare("SELECT node_id, expires_at FROM oauth_states WHERE state = ?") + const row = await env.DB.prepare("SELECT account_id, expires_at FROM oauth_states WHERE state = ?") .bind(state) - .first<{ node_id: string; expires_at: string }>(); + .first<{ account_id: string; expires_at: string }>(); if (!row) return null; await env.DB.prepare("DELETE FROM oauth_states WHERE state = ?").bind(state).run(); if (row.expires_at < nowIso()) return null; - return { nodeId: row.node_id }; + return { accountId: row.account_id }; } /** # 函数 diff --git a/console/src/routes/resources.ts b/console/src/routes/resources.ts index d28d9a5..a707e5c 100644 --- a/console/src/routes/resources.ts +++ b/console/src/routes/resources.ts @@ -1,9 +1,10 @@ /** * Console 管理路由(Account(账号) / Domain / Mailbox / Email)。 - * 全部要求会话登录。v1.1.0: - * - 节点在管理面更名「账号」,路由 /api/accounts(内部 Node 协议 /api/internal/nodes 不变) + * 全部要求会话登录。v2.0.0: + * - 邮件链路改为 Email Worker -> Console(docs/11);账号 = 已连接的 Cloudflare 账户 * - 删除手工添加域名;域名由 Cloudflare OAuth 自动同步 * - POST /domains/:id/setup 为域名配置 Email Workers(Worker + Routing + catch-all) + * - POST /accounts/:id/rotate 轮换账号的 Worker credential(secret 仅一次可见) * - 邮箱/域名级 Webhook 配置(PUT/DELETE .../webhook) */ @@ -11,8 +12,8 @@ import { Hono } from "hono"; import type { Env, AppVars } from "../env.ts"; import { requireSession } from "../middleware.ts"; import { loadSteps, setupDomainEmail, upsertMailbox } from "../services/provision.ts"; -import { decryptSecret } from "../db.ts"; -import { checkWebhookUrl } from "../../../shared/src/index.ts"; +import { encryptSecret, keyHash, decryptConnectionToken, decryptSecret } from "../db.ts"; +import { checkWebhookUrl, ids } from "../../../shared/src/index.ts"; import { normalizeWebhookHeaders, saveScopedWebhook } from "../services/webhook_delivery.ts"; import { nowIso, pageParams, readJson } from "../util.ts"; @@ -63,23 +64,21 @@ function installAccountRoutes(app: App) { 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, - ingress_url, created_at, updated_at - FROM nodes WHERE status != 'revoked' ORDER BY created_at DESC LIMIT ? OFFSET ?`, + `SELECT id, name, cloudflare_account_id, worker_id, status, created_at, updated_at + FROM cloudflare_accounts WHERE status != 'revoked' ORDER BY created_at DESC LIMIT ? OFFSET ?`, ) .bind(limit, offset) .all(); - const total = await c.env.DB.prepare("SELECT COUNT(*) AS n FROM nodes WHERE status != 'revoked'").first<{ n: number }>(); + const total = await c.env.DB.prepare("SELECT COUNT(*) AS n FROM cloudflare_accounts WHERE status != 'revoked'").first<{ n: number }>(); return c.json({ items: rows.results, total: total?.n ?? 0 }); }); - /** 账号详情:含安装步骤状态与 OAuth 连接状态 */ + /** 账号详情:含接入步骤状态、OAuth 连接状态与 Worker credential 信息 */ app.get("/accounts/:id", async (c) => { const id = c.req.param("id"); 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 = ?`, + `SELECT id, name, cloudflare_account_id, worker_id, status, created_at, updated_at + FROM cloudflare_accounts WHERE id = ?`, ) .bind(id) .first(); @@ -88,92 +87,118 @@ function installAccountRoutes(app: App) { const oauth = await c.env.DB.prepare( "SELECT id, provider, account_id, expires_at, created_at FROM oauth_connections ORDER BY created_at DESC LIMIT 1", ).first(); - const domains = await c.env.DB.prepare("SELECT id, name, status, worker_id FROM domains WHERE node_id = ?") + const domains = await c.env.DB.prepare("SELECT id, name, status, worker_id FROM domains WHERE account_id = ?") .bind(id) .all(); - return c.json({ account, steps, oauth: oauth ?? null, domains: domains.results }); + const credential = await c.env.DB.prepare( + "SELECT id, worker_id, secret_last4, status, created_at FROM worker_credentials WHERE cloudflare_account_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1", + ) + .bind(id) + .first(); + return c.json({ account, steps, oauth: oauth ?? null, domains: domains.results, credential: credential ?? null }); }); - /** 更新账号:名称 / 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'") + const body = await readJson<{ name?: string }>(c.req.raw); + const account = await c.env.DB.prepare("SELECT id FROM cloudflare_accounts 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(); + await c.env.DB.prepare("UPDATE cloudflare_accounts SET name = ?, updated_at = ? WHERE id = ?").bind(name, nowIso(), id).run(); } return c.json({ ok: true }); }); - /** 删除账号(软删:撤销 credential + 标记 revoked) */ + /** 删除账号(软删:撤销 Worker 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'", + "UPDATE worker_credentials SET status = 'revoked', revoked_at = ? WHERE cloudflare_account_id = ? AND status = 'active'", ) .bind(nowIso(), id) .run(); - await c.env.DB.prepare("UPDATE nodes SET status = 'revoked', updated_at = ? WHERE id = ?") + await c.env.DB.prepare("UPDATE cloudflare_accounts SET status = 'revoked', updated_at = ? WHERE id = ?") .bind(nowIso(), id) .run(); return c.json({ ok: true }); }); - /** 重新生成 enrollment token(credential 轮换入口) */ + /** 轮换 Worker credential:撤销旧 credential 并签发新 secret(仅本次响应可见); + * 已配置 Email Workers 的域名自动重新部署脚本以携带新 secret。 */ app.post("/accounts/:id/rotate", async (c) => { const id = c.req.param("id"); - 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); - const token = "wenr_" + crypto.randomUUID().replace(/-/g, ""); - await c.env.DB.prepare( - "UPDATE nodes SET enrollment_token = ?, enrollment_expires_at = ?, updated_at = ? WHERE id = ?", + const account = await c.env.DB.prepare( + "SELECT id, worker_id FROM cloudflare_accounts WHERE id = ? AND status != 'revoked'", ) - .bind(token, new Date(Date.now() + 24 * 3600 * 1000).toISOString(), nowIso(), id) - .run(); - return c.json({ ok: true, enrollment_token: token }); - }); - - /** 本地/运维用途:查看 ingress secret(会话保护;真实部署由 provisioning 自动注入 Worker) */ - app.get("/accounts/:id/ingress-secret", async (c) => { - const id = c.req.param("id"); - const account = await c.env.DB.prepare("SELECT ingress_secret_enc FROM nodes WHERE id = ?") .bind(id) - .first<{ ingress_secret_enc: string }>(); + .first<{ id: string; worker_id: string | null }>(); if (!account) return c.json({ error: "not found" }, 404); - return c.json({ ingress_secret: await decryptSecret(c.env, account.ingress_secret_enc) }); + if (!account.worker_id) return c.json({ error: "worker not provisioned yet" }, 400); + + const accessToken = await decryptConnectionToken(c.env); + if (!accessToken) return c.json({ error: "no active Cloudflare OAuth connection" }, 400); + + // 轮换:撤销旧 credential,签发新 credential + await c.env.DB.prepare( + "UPDATE worker_credentials SET status = 'revoked', revoked_at = ? WHERE cloudflare_account_id = ? AND status = 'active'", + ) + .bind(nowIso(), id) + .run(); + const secret = "whs_" + crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, ""); + const credentialId = ids.credential(); + await c.env.DB.prepare( + "INSERT INTO worker_credentials (id, cloudflare_account_id, worker_id, secret_hash, secret_enc, secret_last4, status, created_at) VALUES (?, ?, ?, ?, ?, ?, 'active', ?)", + ) + .bind( + credentialId, + id, + account.worker_id, + await keyHash(secret), + await encryptSecret(c.env, secret), + secret.slice(-4), + nowIso(), + ) + .run(); + + // 已启用 Email Workers 的域名重新部署脚本(幂等) + const domains = await c.env.DB.prepare( + "SELECT id, name FROM domains WHERE account_id = ? AND worker_id IS NOT NULL", + ) + .bind(id) + .all<{ id: string; name: string }>(); + const redeployed: string[] = []; + for (const d of domains.results) { + const r = await setupDomainEmail(c.env, d.id); + if (r.ok) redeployed.push(d.name); + } + return c.json({ ok: true, worker_id: account.worker_id, credential_id: credentialId, secret, redeployed }); + }); + /** 本地/运维用途:查看账号 Worker credential(会话保护;真实部署由 provisioning 自动注入 Worker) */ + app.get("/accounts/:id/worker-secret", async (c) => { + const id = c.req.param("id"); + const cred = await c.env.DB.prepare( + "SELECT worker_id, secret_enc FROM worker_credentials WHERE cloudflare_account_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1", + ) + .bind(id) + .first<{ worker_id: string; secret_enc: string }>(); + if (!cred) return c.json({ error: "not found" }, 404); + return c.json({ worker_id: cred.worker_id, secret: await decryptSecret(c.env, cred.secret_enc) }); }); } - -/** # 函数 - * 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.id, d.name, d.status, d.setup_error, d.worker_id, d.zone_id, d.node_id, + `SELECT d.id, d.name, d.status, d.setup_error, d.worker_id, d.zone_id, d.account_id, d.webhook_url, d.webhook_headers, (d.webhook_key_enc IS NOT NULL) AS webhook_has_key, - n.name AS node_name, + a.name AS account_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 + FROM domains d LEFT JOIN cloudflare_accounts a ON a.id = d.account_id ORDER BY d.created_at DESC LIMIT ? OFFSET ?`, ) .bind(limit, offset) @@ -185,7 +210,7 @@ function installDomainRoutes(app: App) { /** 域名可用的账号(供筛选) */ 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", + "SELECT DISTINCT a.id, a.name FROM cloudflare_accounts a JOIN domains d ON d.account_id = a.id ORDER BY a.name", ).all(); return c.json({ items: rows.results }); }); diff --git a/console/src/routes/settings.ts b/console/src/routes/settings.ts index 100634d..695b762 100644 --- a/console/src/routes/settings.ts +++ b/console/src/routes/settings.ts @@ -1,7 +1,7 @@ /** - * 设置路由(v1.1.0): - * GET /api/settings 读取:全局 Webhook / 默认 Node Ingress 地址 / 当前用户 - * PUT /api/settings 保存:全局 Webhook + 默认 Node Ingress 地址 + * 设置路由(v2.0.0): + * GET /api/settings 读取:全局 Webhook / 当前用户 + * PUT /api/settings 保存:全局 Webhook * PUT /api/settings/password 修改登录密码 * API/MCP Key 管理沿用 /api/keys(前端设置模态窗口内调用)。 */ @@ -10,7 +10,7 @@ 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 { getSetting } from "../db.ts"; import { getGlobalWebhookKey, normalizeWebhookHeaders, @@ -49,32 +49,16 @@ export function settingsRoutes(): App { } return c.json({ username: user?.username ?? "", - default_ingress_url: (await getSetting(c.env, "default_ingress_url")) ?? "", webhook, }); }); - /** 保存设置:全局 Webhook + 默认 Node Ingress 地址 */ + /** 保存设置:全局 Webhook */ 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; diff --git a/console/src/services/cfapi.ts b/console/src/services/cfapi.ts index 8fe7228..5583dfe 100644 --- a/console/src/services/cfapi.ts +++ b/console/src/services/cfapi.ts @@ -3,7 +3,7 @@ * * - RealCFApi:真实调用 api.cloudflare.com / dash.cloudflare.com(OAuth access token) * - SimCFApi :本地 sim 模式。不访问外网,把"创建"的资源记录到 cf_sim_resources 表, - * 让 Add Node 自动安装链路在本地完整可演示、可测试。 + * 让 Connect Cloudflare Account 自动接入链路在本地完整可演示、可测试。 * * Console 通过 env.CF_OAUTH_CLIENT_ID/SECRET 判断模式;两者齐备为 real,否则 sim。 */ @@ -51,13 +51,17 @@ export interface CFApi { listAccounts(accessToken: string): Promise; /** 发现账户下的域名 Zone */ listZones(accessToken: string, accountId: string): Promise; - /** 创建或更新 Email Worker(幂等) */ + /** 创建或更新账号级 Email Worker(幂等) */ putEmailWorker(opts: { accessToken: string; accountId: string; scriptName: string; - ingressUrl: string; - ingressSecret: string; + /** Console 对外地址(Worker -> Console) **/ + consoleUrl: string; + /** Worker 身份(与 WorkerCredential.worker_id 一致) **/ + workerId: string; + /** Worker credential secret(HMAC 签名密钥) **/ + workerSecret: string; }): Promise<{ id: string }>; /** 开启 Zone 的 Email Routing(幂等) */ enableEmailRouting(opts: { accessToken: string; zoneId: string }): Promise<{ enabled: boolean }>; @@ -136,12 +140,13 @@ export class RealCFApi implements CFApi { accessToken: string; accountId: string; scriptName: string; - ingressUrl: string; - ingressSecret: string; + consoleUrl: string; + workerId: string; + workerSecret: string; }) { - // Email Worker 即带 email handler 的 Worker 脚本;ingress 地址与密钥以明文常量打进脚本, - // 避免给 Worker 配置 Console 凭据(docs/08:Worker 只持有 Node ingress secret)。 - const script = renderEmailWorkerScript(opts.ingressUrl, opts.ingressSecret); + // Email Worker 即带 email handler 的 Worker 脚本;Console 地址与 Worker 凭据以明文常量打进脚本, + // 避免给 Worker 配置额外的 secrets 绑定(docs/11 §8:凭据本身经 HMAC 保护 Console API)。 + const script = renderEmailWorkerScript(opts.consoleUrl, opts.workerId, opts.workerSecret); const form = new FormData(); form.append( "metadata", @@ -223,14 +228,16 @@ export class SimCFApi implements CFApi { accessToken: string; accountId: string; scriptName: string; - ingressUrl: string; - ingressSecret: string; + consoleUrl: string; + workerId: string; + workerSecret: string; }) { // 与真实实现同一模板渲染,但只记录不外发 await this.record("email_worker", opts.scriptName, { account_id: opts.accountId, - ingress_url: opts.ingressUrl, - script: renderEmailWorkerScript(opts.ingressUrl, opts.ingressSecret).length, + console_url: opts.consoleUrl, + worker_id: opts.workerId, + script: renderEmailWorkerScript(opts.consoleUrl, opts.workerId, opts.workerSecret).length, }); return { id: opts.scriptName }; } diff --git a/console/src/services/email_ingest.ts b/console/src/services/email_ingest.ts index d445a64..b2a2d7d 100644 --- a/console/src/services/email_ingest.ts +++ b/console/src/services/email_ingest.ts @@ -1,6 +1,6 @@ /** - * 邮件接收管线(docs/06): - * Console 收到 Node 转发的 raw MIME 后: + * 邮件接收管线(docs/06、docs/11): + * Console 收到 Email Worker 提交的 raw MIME 后: * 校验大小限制 -> postal-mime 解析 -> 关联 Mailbox(精确匹配,否则 Domain catch-all)-> * raw 写 R2、附件写 R2 -> emails/email_attachments 入库 -> 计算 expires_at -> * 为启用的 Webhook 创建 delivery(投递失败不影响邮件入库)。 @@ -45,8 +45,8 @@ export interface IngestInput { mailFrom: string; /** SMTP 信封收件人(Email Worker 传递) **/ rcptTo: string; - /** 接收该邮件的 Node ID **/ - nodeId: string; + /** 归属 Console 账号 id(Worker credential 所属账号) **/ + accountId: string; } /** # 成员变量: 入库结果 **/ @@ -161,13 +161,13 @@ export async function ingestEmail(env: Env, input: IngestInput): Promise mailbox 解析:精确匹配优先,其次域名 catch-all + * 收件地址 -> mailbox 解析:精确匹配优先,其次域名 catch-all(仅 active) */ export async function resolveMailbox(env: Env, addresses: string[]): Promise { for (const addr of addresses) { diff --git a/console/src/services/provision.ts b/console/src/services/provision.ts index 8bdb167..35795b8 100644 --- a/console/src/services/provision.ts +++ b/console/src/services/provision.ts @@ -1,12 +1,12 @@ /** - * 账号(Node)自动安装管线(v1.1.0): - * 添加账号(建 Node identity + credential) -> Cloudflare OAuth -> 授权回调 -> - * Account/Zone 发现 -> 同步账号下全部域名(pending)+ catch-all 邮箱。 + * 账号接入管线(docs/11,Connect Cloudflare Account): + * 添加账号(建 cloudflare_accounts 记录)-> Cloudflare OAuth -> 授权回调 -> + * Account/Zone 发现 -> 同步账号下全部域名(pending)+ catch-all 邮箱 -> + * Email Worker 部署 + WorkerCredential 签发(账号级)。 * - * Email Workers(Worker 部署 + Email Routing + catch-all 规则)不随 OAuth 自动执行, - * 而是在域名列表中按域名单独触发(setupDomainEmail),一个账号只部署一个 Email Worker。 - * - * 所有步骤幂等,重复执行不产生重复资源;账号级步骤状态写入 nodes.provision_steps。 + * Email Routing/catch-all 规则不随 OAuth 自动执行,而是在域名列表中按域名单独触发 + * (setupDomainEmail);一个账号只部署一个 Email Worker(幂等,重复执行不产生重复资源)。 + * 账号级步骤状态写入 cloudflare_accounts.provision_steps。 */ import type { Env } from "../env.ts"; @@ -15,12 +15,12 @@ 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: "domains", label: "域名与 catch-all 邮箱同步" }, + { key: "worker", label: "Email Worker 部署与 Worker 凭据" }, ] as const; /** # 成员变量: 步骤状态 **/ @@ -36,164 +36,131 @@ export function initialSteps(): Record Console 的身份 ID) */ -export function emailWorkerNameFor(nodeId: string): string { - return `wmailhub-email-${nodeId.replace(/[^a-z0-9-]/gi, "-").toLowerCase()}`; +export function emailWorkerNameFor(accountId: string): string { + return `wmailhub-email-${accountId.replace(/[^a-z0-9-]/gi, "-").toLowerCase()}`; } /** # 函数 - * 添加账号:创建 node 记录 + node credential(仅存摘要与密文)+ enrollment token。 - * 返回 enrollment token 供 Node bootstrap(本地由 dev 脚本自动完成,生产在 UI 展示一次)。 + * 添加账号:创建 cloudflare_accounts 记录(等待 OAuth 授权)。 */ -export async function createNode( +export async function createAccount( env: Env, - opts: { name: string; ingressUrl: string }, -): Promise<{ - nodeId: string; - enrollmentToken: string; - ingressSecret: string; -}> { - const nodeId = ids.node(); + opts: { name: string }, +): Promise<{ accountId: string }> { + const accountId = ids.account(); const now = nowIso(); - const ingressSecret = "whing_" + crypto.randomUUID().replace(/-/g, ""); - const enrollmentToken = "wenr_" + crypto.randomUUID().replace(/-/g, ""); await env.DB.prepare( - `INSERT INTO nodes (id, name, cloudflare_account_id, credential_id, status, version, - last_seen_at, ingress_url, ingress_secret_enc, enrollment_token, enrollment_expires_at, + `INSERT INTO cloudflare_accounts (id, name, cloudflare_account_id, worker_id, status, provision_steps, created_at, updated_at) - VALUES (?, ?, NULL, NULL, 'pending_oauth', NULL, NULL, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, NULL, NULL, 'pending_oauth', ?, ?, ?)`, + ) + .bind(accountId, opts.name, JSON.stringify(initialSteps()), now, now) + .run(); + return { accountId }; +} + +/** # 函数 + * 确保账号存在可用的 Worker credential: + * 已有 active credential 直接复用(不泄露 secret);否则签发全新 credential。 + * 返回 secret 明文仅供部署注入(本地测试/重新部署 Worker 时使用)。 + */ +export async function ensureWorkerCredential( + env: Env, + accountId: string, + workerId: string, +): Promise<{ credentialId: string; secret: string | null }> { + const now = nowIso(); + const existing = await env.DB.prepare( + "SELECT id FROM worker_credentials WHERE cloudflare_account_id = ? AND worker_id = ? AND status = 'active'", + ) + .bind(accountId, workerId) + .first<{ id: string }>(); + if (existing) return { credentialId: existing.id, secret: null }; + + const secret = "whs_" + crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, ""); + const credentialId = ids.credential(); + await env.DB.prepare( + "INSERT INTO worker_credentials (id, cloudflare_account_id, worker_id, secret_hash, secret_enc, secret_last4, status, created_at) VALUES (?, ?, ?, ?, ?, ?, 'active', ?)", ) .bind( - nodeId, - opts.name, - opts.ingressUrl, - await encryptSecret(env, ingressSecret), - enrollmentToken, - new Date(Date.now() + 24 * 3600 * 1000).toISOString(), - JSON.stringify(initialSteps()), - now, + credentialId, + accountId, + workerId, + await sha256Hex(secret), + await encryptSecret(env, secret), + secret.slice(-4), now, ) .run(); - return { nodeId, enrollmentToken, ingressSecret }; + return { credentialId, secret }; } -/** # 函数 - * Node bootstrap enrollment:用一次性 token 换取正式身份。 - * 每次调用都会轮换出全新 credential(旧的撤销),返回的 secret 仅此一次可见。 - */ -export async function enrollNode( - env: Env, - enrollmentToken: string, -): Promise<{ - node_id: string; - credential_id: string; - credential_secret: string; - ingress_secret: string; - console_url: string | null; -} | null> { - const node = await env.DB.prepare( - "SELECT id, ingress_secret_enc, enrollment_expires_at FROM nodes WHERE enrollment_token = ? AND status != 'revoked'", - ) - .bind(enrollmentToken) - .first<{ id: string; ingress_secret_enc: string; enrollment_expires_at: string }>(); - if (!node) return null; - if (node.enrollment_expires_at && node.enrollment_expires_at < nowIso()) return null; - - // 轮换:撤销旧 credential,签发新 credential - await env.DB.prepare( - "UPDATE node_credentials SET status = 'revoked', revoked_at = ? WHERE node_id = ? AND status = 'active'", - ) - .bind(nowIso(), node.id) - .run(); - const credentialId = ids.credential(); - const secret = "wns_" + crypto.randomUUID().replace(/-/g, "") + crypto.randomUUID().replace(/-/g, ""); - await env.DB.prepare( - "INSERT INTO node_credentials (id, node_id, secret_hash, secret_enc, status, created_at) VALUES (?, ?, ?, ?, 'active', ?)", - ) - .bind(credentialId, node.id, await sha256Hex(secret), await encryptSecret(env, secret), nowIso()) - .run(); - await env.DB.prepare( - "UPDATE nodes SET credential_id = ?, updated_at = ? WHERE id = ?", - ) - .bind(credentialId, nowIso(), node.id) - .run(); - - return { - node_id: node.id, - credential_id: credentialId, - credential_secret: secret, - ingress_secret: await decryptSecret(env, node.ingress_secret_enc), - console_url: null, - }; -} - -/** # 成员变量: 授权完成后的完整安装流程参数 **/ +/** # 成员变量: 授权完成后的完整接入流程参数 **/ export interface ProvisionInput { - nodeId: string; + accountId: string; accessToken: string; - accountId?: string; + cfAccountId?: string; } /** # 函数 - * OAuth 授权成功后的自动安装主流程:发现账号 + 同步全部域名(pending)+ catch-all 邮箱。 - * 幂等,可重复调用;返回是否全部成功;失败时步骤状态里带 error。 + * OAuth 授权成功后的自动接入主流程:发现账号 + 同步全部域名(pending)+ catch-all 邮箱 + + * 部署账号级 Email Worker + 签发 Worker credential。幂等,可重复调用;返回是否全部成功。 */ -export async function provisionNode(env: Env, input: ProvisionInput): Promise<{ ok: boolean; error?: string }> { +export async function provisionAccount(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 FROM nodes WHERE id = ?") - .bind(input.nodeId) - .first<{ id: string; name: string }>(); - if (!node) return { ok: false, error: "account not found" }; + const account = await env.DB.prepare("SELECT id, name, status FROM cloudflare_accounts WHERE id = ?") + .bind(input.accountId) + .first<{ id: string; name: string; status: string }>(); + if (!account || account.status === "revoked") return { ok: false, error: "account not found" }; - const steps = await loadSteps(env, input.nodeId); + const steps = await loadSteps(env, input.accountId); const mark = async (key: string, status: StepStatus, error?: string) => { steps[key] = { status, error }; - await env.DB.prepare("UPDATE nodes SET provision_steps = ?, updated_at = ? WHERE id = ?") - .bind(JSON.stringify(steps), nowIso(), input.nodeId) + await env.DB.prepare("UPDATE cloudflare_accounts SET provision_steps = ?, updated_at = ? WHERE id = ?") + .bind(JSON.stringify(steps), nowIso(), input.accountId) .run(); }; try { - await env.DB.prepare("UPDATE nodes SET status = 'provisioning', updated_at = ? WHERE id = ?") - .bind(nowIso(), input.nodeId) + await env.DB.prepare("UPDATE cloudflare_accounts SET status = 'provisioning', updated_at = ? WHERE id = ?") + .bind(nowIso(), input.accountId) .run(); // 1. OAuth 授权 await mark("oauth", "done"); - // 2. Node identity/credential(在添加账号与 enrollment 时完成) - await mark("identity", "done"); - - // 3. Account/Zone 发现 + // 2. Account/Zone 发现 await mark("discover", "running"); const accounts = await cf.listAccounts(input.accessToken); if (accounts.length === 0) throw new Error("no cloudflare account accessible"); - const account = input.accountId - ? accounts.find((a) => a.id === input.accountId) ?? accounts[0] + const cfAccount = input.cfAccountId + ? accounts.find((a) => a.id === input.cfAccountId) ?? accounts[0] : accounts[0]; // 自动创建的账号以 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) + const newName = account.name.startsWith("Cloudflare 账号") ? cfAccount.name : account.name; + await env.DB.prepare( + "UPDATE cloudflare_accounts SET cloudflare_account_id = ?, name = ?, updated_at = ? WHERE id = ?", + ) + .bind(cfAccount.id, newName, nowIso(), input.accountId) .run(); - const zones = await cf.listZones(input.accessToken, account.id); + const zones = await cf.listZones(input.accessToken, cfAccount.id); await mark("discover", "done"); - // 4. 同步全部域名(pending,等待逐域名「设置 Email Workers」)+ catch-all 邮箱 + // 3. 同步全部域名(pending,等待逐域名「设置 Email Workers」)+ catch-all 邮箱 await mark("domains", "running"); for (const zone of zones) { await upsertDomain(env, { - cloudflareAccountId: account.id, - nodeId: input.nodeId, + cloudflareAccountId: cfAccount.id, + accountId: input.accountId, name: zone.name, zoneId: zone.id, workerId: null, }); } - const domainRows = await env.DB.prepare("SELECT id, name FROM domains WHERE node_id = ?") - .bind(input.nodeId) + const domainRows = await env.DB.prepare("SELECT id, name FROM domains WHERE account_id = ?") + .bind(input.accountId) .all<{ id: string; name: string }>(); for (const d of domainRows.results) { await upsertMailbox(env, { @@ -204,8 +171,13 @@ export async function provisionNode(env: Env, input: ProvisionInput): Promise<{ } await mark("domains", "done"); - await env.DB.prepare("UPDATE nodes SET status = 'offline', updated_at = ? WHERE id = ?") - .bind(nowIso(), input.nodeId) + // 4. 部署账号级 Email Worker + 签发 Worker credential(docs/11 §14) + await mark("worker", "running"); + await deployAccountWorker(env, input.accountId, input.accessToken); + await mark("worker", "done"); + + await env.DB.prepare("UPDATE cloudflare_accounts SET status = 'active', updated_at = ? WHERE id = ?") + .bind(nowIso(), input.accountId) .run(); return { ok: true }; } catch (err) { @@ -217,52 +189,104 @@ export async function provisionNode(env: Env, input: ProvisionInput): Promise<{ break; } } - await env.DB.prepare("UPDATE nodes SET status = 'error', updated_at = ? WHERE id = ?") - .bind(nowIso(), input.nodeId) + await env.DB.prepare("UPDATE cloudflare_accounts SET status = 'error', updated_at = ? WHERE id = ?") + .bind(nowIso(), input.accountId) .run(); return { ok: false, error: message }; } } /** # 函数 - * 为单个域名配置 Email Workers:部署账号级 Email Worker -> 开启 Email Routing -> + * 部署(或更新)账号级 Email Worker: + * 确保 Worker credential -> 渲染脚本(Console 地址 + Worker ID + secret)-> 上传 Cloudflare。 + * credential 已存在时复用旧 secret(重新上传脚本不轮换密钥);secret 仅在首次签发时用于部署。 + */ +export async function deployAccountWorker(env: Env, accountId: string, accessToken: string): Promise { + const account = await env.DB.prepare("SELECT id, cloudflare_account_id FROM cloudflare_accounts WHERE id = ?") + .bind(accountId) + .first<{ id: string; cloudflare_account_id: string | null }>(); + if (!account?.cloudflare_account_id) throw new Error("account not connected via OAuth"); + + const workerId = await getOrCreateWorkerId(env, accountId); + const { secret } = await ensureWorkerCredential(env, accountId, workerId); + if (!secret) { + // 已有 credential:重新上传脚本时需要原 secret,先解密 + const row = await env.DB.prepare( + "SELECT secret_enc FROM worker_credentials WHERE cloudflare_account_id = ? AND worker_id = ? AND status = 'active' ORDER BY created_at DESC LIMIT 1", + ) + .bind(accountId, workerId) + .first<{ secret_enc: string }>(); + const decrypted = row ? await decryptSecret(env, row.secret_enc) : null; + await putWorkerScript(env, accessToken, account.cloudflare_account_id, workerId, decrypted ?? ""); + return workerId; + } + await putWorkerScript(env, accessToken, account.cloudflare_account_id, workerId, secret); + return workerId; +} + +/** # 函数 + * 账号的 worker_id(幂等:无则生成并写入 cloudflare_accounts.worker_id) + */ +async function getOrCreateWorkerId(env: Env, accountId: string): Promise { + const row = await env.DB.prepare("SELECT worker_id FROM cloudflare_accounts WHERE id = ?") + .bind(accountId) + .first<{ worker_id: string | null }>(); + if (row?.worker_id) return row.worker_id; + const workerId = emailWorkerNameFor(accountId); + await env.DB.prepare("UPDATE cloudflare_accounts SET worker_id = ?, updated_at = ? WHERE id = ?") + .bind(workerId, nowIso(), accountId) + .run(); + return workerId; +} + +/** # 函数 + * 上传 Email Worker 脚本(Console 地址取 PUBLIC_BASE_URL,与 OAuth redirect 同源) + */ +async function putWorkerScript( + env: Env, + accessToken: string, + cfAccountId: string, + workerId: string, + workerSecret: string, +): Promise { + const cf: CFApi = getCFApi(env, env.DB); + const consoleUrl = env.PUBLIC_BASE_URL ?? ""; + if (!consoleUrl) throw new Error("console public url unknown (PUBLIC_BASE_URL)"); + await cf.putEmailWorker({ accessToken, accountId: cfAccountId, scriptName: workerId, consoleUrl, workerId, workerSecret }); +} + +/** # 函数 + * 为单个域名配置 Email Workers:确保账号级 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 = ?", + "SELECT id, name, zone_id, account_id FROM domains WHERE id = ?", ) .bind(domainId) - .first<{ id: string; name: string; zone_id: string | null; node_id: string | null }>(); + .first<{ id: string; name: string; zone_id: string | null; account_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" }; + if (!domain.account_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'", + const account = await env.DB.prepare( + "SELECT id, cloudflare_account_id FROM cloudflare_accounts 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" }; + .bind(domain.account_id) + .first<{ id: string; cloudflare_account_id: string | null }>(); + if (!account?.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), - }); + const workerId = await deployAccountWorker(env, account.id, accessToken); await cf.enableEmailRouting({ accessToken, zoneId: domain.zone_id }); - await cf.setCatchAllRule({ accessToken, zoneId: domain.zone_id, workerName }); + await cf.setCatchAllRule({ accessToken, zoneId: domain.zone_id, workerName: workerId }); await env.DB.prepare( "UPDATE domains SET worker_id = ?, status = 'active', setup_error = NULL, updated_at = ? WHERE id = ?", ) - .bind(workerName, nowIso(), domainId) + .bind(workerId, nowIso(), domainId) .run(); return { ok: true }; } catch (err) { @@ -275,11 +299,11 @@ export async function setupDomainEmail(env: Env, domainId: string): Promise<{ ok } /** # 函数 - * 读取并解析 node 的步骤状态 + * 读取并解析账号的步骤状态 */ -export async function loadSteps(env: Env, nodeId: string) { - const row = await env.DB.prepare("SELECT provision_steps FROM nodes WHERE id = ?") - .bind(nodeId) +export async function loadSteps(env: Env, accountId: string) { + const row = await env.DB.prepare("SELECT provision_steps FROM cloudflare_accounts WHERE id = ?") + .bind(accountId) .first<{ provision_steps: string }>(); if (!row?.provision_steps) return initialSteps(); try { @@ -290,13 +314,13 @@ export async function loadSteps(env: Env, nodeId: string) { } /** # 函数 - * upsert Domain(按 account+name 幂等)。无 workerId 时保持/置为 pending(待配置 Email Workers)。 + * upsert Domain(按 cf account+name 幂等)。无 workerId 时保持/置为 pending(待配置 Email Workers)。 */ export async function upsertDomain( env: Env, d: { cloudflareAccountId: string; - nodeId: string; + accountId: string; name: string; zoneId: string; workerId: string | null; @@ -311,23 +335,23 @@ export async function upsertDomain( if (existing) { // 已配置过 Worker 的域名不回退状态;仅当新配置带 worker 时更新 if (existing.worker_id || !d.workerId) { - await env.DB.prepare("UPDATE domains SET node_id = ?, zone_id = ?, updated_at = ? WHERE id = ?") - .bind(d.nodeId, d.zoneId, now, existing.id) + await env.DB.prepare("UPDATE domains SET account_id = ?, zone_id = ?, updated_at = ? WHERE id = ?") + .bind(d.accountId, 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 = ?", + "UPDATE domains SET account_id = ?, zone_id = ?, worker_id = ?, status = 'active', updated_at = ? WHERE id = ?", ) - .bind(d.nodeId, d.zoneId, d.workerId, now, existing.id) + .bind(d.accountId, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?)", + "INSERT INTO domains (id, cloudflare_account_id, account_id, name, status, worker_id, zone_id, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)", ) - .bind(id, d.cloudflareAccountId, d.nodeId, d.name, d.workerId ? "active" : "pending", d.workerId, d.zoneId, now, now) + .bind(id, d.cloudflareAccountId, d.accountId, d.name, d.workerId ? "active" : "pending", d.workerId, d.zoneId, now, now) .run(); return id; } diff --git a/docs/00_OVERVIEW.md b/docs/00_OVERVIEW.md index 4954b4a..b225b27 100644 --- a/docs/00_OVERVIEW.md +++ b/docs/00_OVERVIEW.md @@ -1,18 +1,19 @@ -# WmailHub 开发设计总览 +# WmailHub 开发设计总览(v2,docs/11) ## 1. 项目定位 WmailHub 是一个基于 Cloudflare Email Workers 的轻量邮件接收与转发平台。 -核心原则: +核心原则: -- Cloudflare 负责邮件基础设施。 -- Console 是控制平面,包含前端与管理 Node。 -- Node 是邮件数据转发平面,无前端、无业务判断。 -- Console 通过 Cloudflare OAuth 自动完成 Node、Domain、Mailbox、Email Worker 的配置。 -- Node 只能处理被授权 Cloudflare Account 下的域名邮箱。 -- Node 与 Console 的通信必须使用 HTTPS + 应用层签名。 -- 邮件默认短期保存,例如 1 天,过期自动删除。 +- 不自建 SMTP、IMAP、POP3。 +- 不部署邮件转发 Node(v1 的 Node 已从邮件链路移除)。 +- Email Worker 是邮件入口 + SMTP reject 执行者。 +- Console 是唯一业务控制中心,也是"是否允许接收邮件"的唯一事实来源。 +- Worker 不保存邮箱业务数据;邮箱、域名、启用状态全部由 Console 管理。 +- Console 明确允许时,Worker 接收邮件;明确拒绝时执行 `message.setReject()`; + Console 超时或 5xx 不能被解释成邮箱不存在。 +- 邮件默认短期保存,例如 1 天,过期自动删除。 - 邮件进入 Console 后才能进入 Webhook、API、MCP 等业务能力。 ## 2. 总体架构 @@ -25,14 +26,12 @@ Cloudflare Email Routing | v Cloudflare Email Worker - | + | HTTPS + HMAC(authorize / ingest) v -Node - | HTTPS + Request Signature - v -Console Management Node +Console | - +--> Database + +--> D1 + +--> R2 +--> Webhook +--> API +--> MCP @@ -41,60 +40,56 @@ Console Management Node ## 3. Console -Console = Frontend + Management Node。 +Console = Frontend + Management API。 -负责:Cloudflare OAuth、Cloudflare Account、Node、Domain、Mailbox、Email、Webhook、API、MCP、Node 安装与配置、数据存储、TTL 清理调度。 +负责:Cloudflare OAuth、Cloudflare Account、Domain、Mailbox、邮箱启用/禁用、Email Storage、 +Webhook、REST API、MCP、TTL Cleanup、Cloudflare Email Routing 配置、Email Worker 部署、 +Worker Credential。 -用户不执行 Node 安装命令。点击 Add Node 后进入 Cloudflare OAuth,授权后由 Cloudflare API 自动完成安装和配置。 +用户不执行安装命令。点击「添加账号」进入 Cloudflare OAuth(Connect Cloudflare Account), +授权后由 Cloudflare API 自动完成域名同步、Worker 部署与凭据配置。 -## 4. Node +## 4. Email Worker -Node 是纯后端组件,只做:接收 Cloudflare Email Worker 的邮件、将邮件安全转发到 Console。 +Worker 只做:接收 Cloudflare Email Routing 邮件 -> authorize(询问 Console)-> +reject 时 `message.setReject()` -> accept 时把 raw MIME 提交 ingest。 -Node 不做 Domain 判断、Mailbox 判断、邮件业务路由、Webhook、MCP、API、用户权限、邮件长期存储。 - -Node 收到什么就转发什么。 +不做邮箱/域名管理、用户权限、Webhook、API、MCP、D1/R2 业务查询、TTL、邮箱业务规则。 ## 5. 权限边界 -一个 Node 与一个 Cloudflare Account 建立绑定。Node A 只能处理 Account A 下配置的 Email Worker,不允许处理其他 Account。 +一个账号绑定一个 Cloudflare Account 与一个 Email Worker;Worker credential 只能处理 +所属账号下的域名邮箱(docs/04 归属校验)。 -Domain 与 Mailbox 的具体业务信息由 Console 管理,Node 不需要理解这些业务字段。 - -## 6. 核心邮件流程 +## 6. 核心邮件流程(两阶段) ```text Email | v -Cloudflare Email Routing +Cloudflare Email Routing(catch-all) | v Email Worker | v -Node - | - v -Console - | - +--> Store - +--> Webhook - +--> API - +--> MCP +Console /internal/email/authorize + +-- accept -> /internal/email/ingest -> D1 + R2 + Webhook + +-- reject -> message.setReject() + +-- temporary_error -> 交由 Cloudflare 邮件机制重试 ``` ## 7. 生命周期 -默认邮件 TTL 为 1 天。数据库记录 expires_at,Cron 定期删除过期记录及对应 R2 对象。 +默认邮件 TTL 为 1 天。数据库记录 expires_at,Cron 定期删除过期记录及对应 R2 对象。 ## 8. 第一版明确不做 - 自建 SMTP Server - IMAP / POP3 -- 发件服务器 +- 发件服务器 / NDR(反站) - 完整邮箱客户端 - 复杂邮件规则引擎 - AI 邮件分析 - 多租户复杂组织系统 -- Node 端业务路由 +- KV/DO 邮箱缓存与同步(性能需要时再评估,须接受传播延迟) diff --git a/docs/01_ARCHITECTURE.md b/docs/01_ARCHITECTURE.md index 54a7e9d..0d06283 100644 --- a/docs/01_ARCHITECTURE.md +++ b/docs/01_ARCHITECTURE.md @@ -1,31 +1,57 @@ -# WmailHub 系统架构 +# WmailHub 系统架构(v2,docs/11) + +最终架构是两层邮件接收: + +```text +Internet + | + v +Cloudflare Email Routing + | + v +Email Worker + | + | HTTPS + HMAC(authorize / ingest) + v +WmailHub Console + | + +-- D1 + +-- R2 + +-- Webhook + +-- API + +-- MCP +``` + +Node 已从邮件架构中移除(v1 的 `Email Worker -> Node -> Console` 链路废弃)。 ## Console -Console = Frontend + Management Node,是系统控制中心。 +Console = Frontend + Management API,是系统唯一业务控制中心。 -负责 Cloudflare OAuth、Node、Domain、Mailbox、Email、Webhook、API、MCP 以及数据存储。 +负责 Cloudflare OAuth、Cloudflare Account、Domain、Mailbox、邮箱启用/禁用、Email Storage(D1 + R2)、Webhook、REST API、MCP、TTL Cleanup、Email Routing 配置、Email Worker 部署与 Worker Credential。 -## Node +Console 是"是否允许接收邮件"的唯一事实来源。 -Node 是纯后端、无前端的邮件转发节点。 +## Email Worker -唯一核心流程:Cloudflare Email Worker -> Node -> Console。 +Email Worker 是邮件入口 + SMTP reject 执行者,只负责: -Node 不负责 Domain、Mailbox、Webhook、MCP、API、用户权限或业务路由。 +1. 接收 Cloudflare Email Routing 邮件,读取 `message.from` / `message.to`; +2. 调用 Console `POST /internal/email/authorize` 询问是否接收; +3. 明确拒绝时执行 `message.setReject()`; +4. 明确允许时把 raw MIME 提交到 Console `POST /internal/email/ingest`; +5. 处理内部异常(超时/5xx 一律按临时错误,不伪装成邮箱不存在)。 -## Cloudflare Worker - -Email Worker 是邮件入口,只负责接收 Cloudflare Email Event 并安全转发给 Node。 +Worker 不创建邮箱、不做域名管理、不存业务数据、不实现 Webhook/API/MCP。 ## 控制平面 ```text Cloudflare Account - +-- Node +-- Domain +-- Mailbox - +-- Email Worker + +-- Email Worker(账号级一个) + +-- WorkerCredential ``` 这些资源关系由 Console 管理。 @@ -33,15 +59,9 @@ Cloudflare Account ## 数据平面 ```text -Email Worker -> Node -> Console +Email Worker -> Console(authorize + ingest)-> D1 / R2 / Webhook ``` -Node 不参与业务决策。 +## 归属范围 -## Node Scope - -第一版一个 Node 绑定一个 Cloudflare Account。Node A 不允许处理 Account B 的资源。 - -## 扩展 - -未来可以有多个 Node,例如 Node JP、Node US、Node EU。每个 Node 默认仍绑定一个 Cloudflare Account。 \ No newline at end of file +一个账号绑定一个 Cloudflare Account 与一个 Email Worker;Worker credential 不允许处理其它账号的域名。 diff --git a/docs/02_CLOUDFLARE_OAUTH.md b/docs/02_CLOUDFLARE_OAUTH.md index 479ef75..b41f0dd 100644 --- a/docs/02_CLOUDFLARE_OAUTH.md +++ b/docs/02_CLOUDFLARE_OAUTH.md @@ -1,11 +1,34 @@ -# Cloudflare OAuth 与自动安装 +# Cloudflare OAuth 与自动接入(docs/11 §14) -Console 的 Add Node 入口必须打开 Cloudflare OAuth 授权页面。用户授权后,Console 通过 Cloudflare API 自动完成 Node、Email Worker、Domain、Mailbox 的配置。 +旧「Add Node」概念已删除,改为 **Connect Cloudflare Account**。 -用户不执行 shell 安装命令,也不手工创建 API Token。 +## 流程 -流程:Console -> Add Node -> Cloudflare OAuth -> 授权回调 -> 获取 Account/Zone -> 创建 Node Identity -> 创建 Node Credential -> 创建或更新 Email Worker -> 配置 Email Routing -> 创建 Domain/Mailbox -> Node Ready。 +```text +Console + | + v +Cloudflare OAuth + | + v +用户授权 Account + | + v +Console + +-- 保存 Account + +-- Account / Zone 发现 + +-- 配置 Domain(同步账号下全部域名,pending) + +-- 创建 catch-all Mailbox + +-- 部署账号级 Email Worker + +-- 签发 WorkerCredential(secret 注入 Worker 脚本) +``` -Cloudflare credential 与 Node credential 必须分离。Cloudflare credential 加密存储,不进入前端和日志。 +用户不需要执行安装命令,也不手工创建 API Token。 -安装过程必须幂等,重复执行不能创建重复资源。OAuth state、授权回调和 Account ownership 必须验证。 \ No newline at end of file +## 安全 + +- Cloudflare credential(OAuth access token)AES-GCM 加密存储,不进入前端和日志。 +- OAuth state 一次性(用后即删)、5 分钟过期、绑定账号(CSRF 防护)。 +- Account ownership 以 token 实际可访问的 accounts 为准。 +- 安装过程幂等,重复执行不产生重复资源。 +- Worker credential 与 Cloudflare credential 分离;轮换 credential 后已配置域名自动重新部署。 diff --git a/docs/03_NODE.md b/docs/03_NODE.md index 0b4acc0..0cc021e 100644 --- a/docs/03_NODE.md +++ b/docs/03_NODE.md @@ -1,44 +1,26 @@ -# Node 设计 +# Node(已移除,docs/11 §4) -Node 是无前端、纯后端的邮件转发节点。 +v2 起 WmailHub 邮件架构中**不存在 Node**。旧方案 `Cloudflare -> Email Worker -> Node -> Console` +废弃,新方案为 `Cloudflare -> Email Worker -> Console`。 -核心流程: +## 为什么删除 Node -```text -Email Worker -> Node -> Console -``` +Node 原来的职责只有「接收邮件 -> 转发邮件」,却额外引入: -## Node 职责 +- 一次网络跳转 +- 一套部署 +- 一套认证 +- 一个故障点 +- 一套重试 +- 一套日志 +- 一套版本管理 -- 接收 Email Worker 请求 -- 验证 Worker 请求 -- 获取邮件 -- 安全转发到 Console -- 有限重试临时网络错误 -- 心跳 -- 上报版本和状态 +Email Worker 已经能够接收邮件、读取 envelope sender/recipient、执行业务前置判断(authorize)、 +调用 HTTPS API(ingest)、执行 `message.setReject()` 并把原始邮件交给 Console,因此 Node 没有独立价值。 -## Node 不负责 +## 现状 -- Domain 路由 -- Mailbox 路由 -- Webhook -- MCP -- API -- 用户权限 -- 邮件业务存储 -- 邮件规则 - -Node 收到什么就转发什么。 - -## Node Scope - -Node 只能服务创建它的 Cloudflare Account。不得通过普通业务请求把 Node 任意绑定到其他 Account。 - -## 配置 - -Node 至少需要 node_id、cloudflare_account_id、console_endpoint、credential。 - -credential secret 必须安全保存。 - -Node 可以有限重试 Console 网络错误,但不承担长期邮件队列职责。 +- Console 的 `/api/internal/nodes/*` 内部协议与 `/internal/enroll` 端点已删除。 +- 数据模型不再有 nodes/node_credentials;账号实体为 cloudflare_accounts, + Email Worker 与 Console 的认证凭据为 worker_credentials(docs/05)。 +- 账号概念保留:「账号」= 已连接的 Cloudflare Account(Connect Cloudflare Account 流程,docs/02)。 diff --git a/docs/04_NODE_PROTOCOL.md b/docs/04_NODE_PROTOCOL.md index f86774e..3d19ad9 100644 --- a/docs/04_NODE_PROTOCOL.md +++ b/docs/04_NODE_PROTOCOL.md @@ -1,30 +1,12 @@ -# Node 与 Console 通信协议 +# Email Worker <-> Console 内部协议(docs/11 §8,协议版本 1) -Node -> Console 必须使用 HTTPS + HMAC 请求签名 + timestamp + nonce + body hash。 +`/internal/email/authorize` 与 `/internal/email/ingest` 都是内部 API,不能匿名访问。 +每个请求必须满足:HTTPS + Worker ID + timestamp + nonce + body SHA256 + HMAC-SHA256。 -## Credential +## 请求头 ```text -node_id -credential_id -credential_secret -cloudflare_account_id -``` - -Console 保存 secret 的安全摘要;Node 保存实际 secret。 - -## 签名 - -```text -body_hash = SHA256(raw_body) -canonical = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + body_hash -signature = HMAC-SHA256(secret, canonical) -``` - -请求头: - -```text -Authorization: WmailHub-Node +Authorization: WmailHub-Worker X-WmailHub-Timestamp: X-WmailHub-Nonce: X-WmailHub-Body-SHA256: @@ -32,18 +14,29 @@ X-WmailHub-Signature: X-WmailHub-Protocol: 1 ``` -Console 拒绝 timestamp 超窗、重复 nonce、错误 signature、错误 body hash、已撤销 credential。默认 timestamp window 建议 5 分钟。 +## 签名 + +```text +body_hash = SHA256(raw_body) +canonical = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + body_hash +signature = HMAC-SHA256(worker_credential_secret, canonical) +``` + +## Console 必须校验 + +1. Worker ID(存在且 credential active); +2. timestamp(默认窗口 5 分钟); +3. nonce 不重复(唯一索引入库,主键冲突即 replay); +4. body hash; +5. HMAC; +6. credential 状态(撤销立即生效); +7. Worker 所属 Cloudflare 账号与收件域名一致。 ## API ```text -POST /api/internal/nodes/{node_id}/emails -POST /api/internal/nodes/{node_id}/heartbeat -GET /api/internal/nodes/{node_id}/config +POST /internal/email/authorize { "recipient": "...", "sender": "...", "message_id": "..." } +POST /internal/email/ingest raw MIME(message/rfc822),收件人经 X-WmailHub-Recipient/Sender 头传递 ``` -邮件请求推荐 `Content-Type: message/rfc822`,body 直接使用 raw MIME。 - -Console 可以立即撤销 credential。Node 收到 401/403 后不得无限重试。 - -协议必须版本化。 +Console 收到 401/403 的 Worker 应视为凭据失效,不得无限重试。协议必须版本化。 diff --git a/docs/05_DATA_MODEL.md b/docs/05_DATA_MODEL.md index 72115c8..4d1d640 100644 --- a/docs/05_DATA_MODEL.md +++ b/docs/05_DATA_MODEL.md @@ -1,34 +1,44 @@ -# 数据模型 +# 数据模型(v2,docs/11 §13) -核心实体:CloudflareAccount、Node、Domain、Mailbox、Email、EmailAttachment、Webhook、WebhookDelivery、OAuthConnection。 +核心实体:CloudflareAccount、Domain、Mailbox、Email、EmailAttachment、Webhook(三级配置)、WebhookDelivery、OAuthConnection、WorkerCredential。 -## nodes +Node 作为邮件链路核心模型已删除(旧 nodes / node_credentials 表由迁移 0003 移除, +账号实体更名为 cloudflare_accounts,凭据更换为 worker_credentials)。 + +## cloudflare_accounts ```text -id, name, cloudflare_account_id, credential_id, status, version, last_seen_at, created_at, updated_at +id, name, cloudflare_account_id, worker_id, status, provision_steps, created_at, updated_at ``` +status:pending_oauth | provisioning | active | error | revoked。 +worker_id 同时是 Email Worker 脚本名与 Worker -> Console 的身份 ID。 + ## domains ```text -id, cloudflare_account_id, node_id, name, status, worker_id, created_at, updated_at +id, cloudflare_account_id, account_id, name, status, worker_id, zone_id, +webhook_url, webhook_headers, webhook_key_enc, setup_error, created_at, updated_at ``` +status:pending(未配置 Email Workers)| active | error。 + ## mailboxes ```text -id, domain_id, address, status, retention_seconds, created_at, updated_at +id, domain_id, address, status, retention_seconds, is_catch_all, +webhook_url, webhook_headers, webhook_key_enc, created_at, updated_at ``` ## emails ```text -id, node_id, mailbox_id nullable, message_id, from_address, to_addresses, +id, account_id, mailbox_id nullable, message_id, from_address, to_addresses, cc_addresses, subject, text_body, html_body, raw_storage_key, size, received_at, expires_at, created_at ``` -Node 可以不知道 mailbox_id。Console 收到邮件后,根据邮件内容和自身配置关联。 +Console 收到邮件后根据收件人/内容与自身配置关联 mailbox。 ## email_attachments @@ -36,17 +46,17 @@ Node 可以不知道 mailbox_id。Console 收到邮件后,根据邮件内容 id, email_id, filename, content_type, size, storage_key, content_id ``` -## webhooks +## Webhook(三级:邮箱 -> 域名 -> 全局) + +域名/邮箱行内配置(webhook_url / webhook_headers / webhook_key_enc), +全局配置存 settings.global_webhook;独立 webhooks 实体已移除。 ```text -id, name, endpoint, secret, enabled, created_at, updated_at +webhook_deliveries: id, email_id, target_type(mailbox|domain|global), +url, headers, key_enc, status, attempt_count, next_retry_at, last_error, delivered_at, created_at ``` -## webhook_deliveries - -```text -id, webhook_id, email_id, status, attempt_count, next_retry_at, last_error, delivered_at, created_at -``` +投递行在入队时冻结解析出的配置,重试不受后续配置变更影响。 ## oauth_connections @@ -54,4 +64,18 @@ id, webhook_id, email_id, status, attempt_count, next_retry_at, last_error, deli id, provider, account_id, encrypted_access_token, refresh_token, expires_at, created_at, updated_at ``` -OAuth token 必须加密存储。所有 Email 必须有 expires_at,默认 retention 为 86400 秒。 +OAuth token 必须加密存储。所有 Email 必须有 expires_at,默认 retention 为 86400 秒。 + +## worker_credentials + +```text +id, cloudflare_account_id, worker_id, secret_hash, secret_enc, secret_last4, +status(active | revoked), created_at, revoked_at +``` + +用于 Email Worker 与 Console 内部认证:secret_hash 为摘要,secret_enc 为 AES-GCM 密文 +(HMAC 验签需要原文,加密存储防拖库泄露);明文 secret 仅在签发/轮换时返回一次。 + +## 其它 + +users、settings、api_credentials(wmh_ Key)、nonces、oauth_states、cf_sim_resources(本地 sim)。 diff --git a/docs/06_EMAIL_LIFECYCLE.md b/docs/06_EMAIL_LIFECYCLE.md index 0a431a5..e526a66 100644 --- a/docs/06_EMAIL_LIFECYCLE.md +++ b/docs/06_EMAIL_LIFECYCLE.md @@ -1,42 +1,60 @@ -# Email 生命周期与 TTL +# Email 生命周期与 TTL(v2,docs/11 §3/§10) -## 接收 +## 接收(两阶段) + +第一阶段只回答"这封邮件是否应该由 WmailHub 接收?": ```text -Cloudflare -> Email Worker -> Node -> Console +Cloudflare -> Email Worker + -> POST /internal/email/authorize { recipient, sender } + +-- accept 继续第二阶段 + +-- reject message.setReject()(SMTP 永久拒绝) + +-- temporary_error 由 Cloudflare 邮件机制处理后续重试 ``` -Console 验证 Node 身份和请求完整性,然后解析 MIME、提取 header、text/html 和附件,保存元数据,raw MIME/大附件写入 R2,计算 expires_at,并创建 Webhook Delivery。 +Console 拒绝码:DOMAIN_NOT_FOUND / DOMAIN_DISABLED / MAILBOX_NOT_FOUND / MAILBOX_DISABLED / RECEIVING_DISABLED。 +Console 超时、网络失败、5xx 都不能被解释成邮箱不存在,也不能伪造永久拒绝。 + +第二阶段(accept 后)存储邮件: + +```text +Email Worker -> POST /internal/email/ingest(raw MIME) + -> Console:验证 Worker 身份与 recipient -> 解析 MIME -> D1 元数据 + -> R2 raw MIME / 附件 -> 计算 expires_at -> 创建 Webhook Delivery +``` + +SMTP 判断与邮件存储完全分离。动态邮箱:创建/删除/禁用邮箱后,下一封邮件的 authorize 立即生效, +Worker 不需要保存 mailbox 列表。 ## TTL -默认保留 1 天: +默认保留 1 天,可按 Mailbox 配置: ```text received_at = 2026-09-30T10:00:00Z expires_at = 2026-10-01T10:00:00Z ``` -Retention 可按 Mailbox 配置。 - ## Cron -使用 Cloudflare Cron Trigger,建议每 15 分钟执行一次清理。实际频率根据当前 Cloudflare 限制调整。 +使用 Cloudflare Cron Trigger(本地每 60s 模拟): ```text Cron -> 查询 expires_at <= now -> 删除 R2 -> 删除附件 -> 删除 Email + + 过期 nonce / OAuth state 清理 + 过期 webhook delivery 重试 ``` 删除必须幂等。 ## Webhook -邮件保存成功后再执行 Webhook。Webhook 失败不能导致邮件重新进入 Node。 +邮件保存成功后投递(三级解析:邮箱 -> 域名 -> 全局)。Webhook 失败不能导致邮件重新进入接收流程。 ## 异常 -R2 删除失败时保留数据库记录并记录 cleanup error,下一次 Cron 重试。数据库已删除但 R2 删除失败时,需要 orphan cleanup。 +R2 删除失败时保留数据库记录并记录 cleanup error,下一次 Cron 重试; +数据库已删除但 R2 删除失败时,由 orphan cleanup 兜底。ingest 失败不伪造业务拒绝。 ## 数据最小化 -邮件、附件、raw MIME、Webhook Delivery 和临时日志都应有生命周期,不做无限期保存。 +邮件、附件、raw MIME、Webhook Delivery 和临时日志都应有生命周期,不做无限期保存。 diff --git a/docs/07_WEBHOOK_API_MCP.md b/docs/07_WEBHOOK_API_MCP.md index a4c7758..509d0f2 100644 --- a/docs/07_WEBHOOK_API_MCP.md +++ b/docs/07_WEBHOOK_API_MCP.md @@ -1,10 +1,11 @@ # Webhook、API 与 MCP -Webhook、API、MCP 全部属于 Console,Node 不实现这些能力。 +Webhook、API、MCP 全部属于 Console,Email Worker 不实现这些能力。 -## Webhook +## Webhook(三级:邮箱 -> 域名 -> 全局) -邮件保存成功后创建 delivery。Payload: +收到邮件后按 邮箱 -> 域名 -> 全局 的顺序取第一个已配置的 Webhook(无配置则逐级回退), +入队 delivery(冻结当时的 url/headers/key 配置)。Payload(docs/07): ```json { @@ -19,27 +20,37 @@ Webhook、API、MCP 全部属于 Console,Node 不实现这些能力。 } ``` -Webhook 必须签名,建议提供 X-WmailHub-Event、X-WmailHub-Delivery、X-WmailHub-Timestamp、X-WmailHub-Signature。 +请求头:X-WmailHub-Event、X-WmailHub-Delivery、X-WmailHub-Timestamp、X-WmailHub-Signature +(签名 = HMAC-SHA256(key, `${timestamp}.${body}`));配置了 key 时默认携带 +`Authorization: Bearer `,自定义 Authorization 头优先;无 key 时不携带签名头。 -2xx 为成功;5xx 和 timeout 重试;4xx 按策略处理。使用指数退避和最大尝试次数。 +2xx 为成功;5xx 和 timeout 按指数退避重试,达到最大尝试次数后置 exhausted。 ## API ```text -/api/v1/nodes +/api/v1/accounts /api/v1/domains /api/v1/mailboxes /api/v1/emails -/api/v1/webhooks /api/v1/mcp ``` -API 属于 Console。 +API 属于 Console,Bearer API Key(wmh_)认证。 ## MCP -MCP 允许 Agent 查询邮箱、获取邮件、查询邮件状态、管理邮箱和 Webhook。Node 不暴露 MCP。 +MCP 允许 Agent 查询邮箱、获取邮件、创建/删除邮箱(list_mailboxes、list_emails、get_email、 +create_mailbox、delete_mailbox),Bearer MCP Key(wmh_)认证。 -MCP/API 获取邮件时必须检查用户权限、邮件是否过期、mailbox scope 和 API credential scope。 +MCP/API 获取邮件时必须检查邮件是否过期、mailbox scope 和 API credential scope。 -Webhook endpoint 必须防 SSRF,至少阻止 localhost、private IPv4/IPv6、link-local、metadata endpoint 和 DNS rebinding。 +## 内部 API + +`/internal/email/authorize` 与 `/internal/email/ingest` 为 Email Worker 专用(docs/04), +与 Webhook/API/MCP 无关。 + +## SSRF + +Webhook endpoint 必须防 SSRF,至少阻止 localhost、private IPv4/IPv6、link-local、 +metadata endpoint 和 DNS rebinding。 diff --git a/docs/08_SECURITY.md b/docs/08_SECURITY.md index 51c1557..386bc63 100644 --- a/docs/08_SECURITY.md +++ b/docs/08_SECURITY.md @@ -1,47 +1,47 @@ -# 安全设计 +# 安全设计(v2,docs/11 §18) ## 安全边界 ```text -Cloudflare Email Worker - | - | authenticated request - v - Node - | - | TLS + HMAC - v - Console +Internet -> Cloudflare Email Routing -> Email Worker + | + | HTTPS + HMAC + timestamp + nonce + body hash + v + Console(/internal/email/*) ``` +公网不得直接调用 /internal/email/authorize 与 /internal/email/ingest: +所有请求必须携带有效的 Worker credential 签名(docs/04)。 + ## Cloudflare OAuth -OAuth Token 加密存储,不进入前端和日志,也不作为 Node credential。 +OAuth Token 加密存储,不进入前端和日志,也不作为 Worker credential。 -## Node Credential +## Worker Credential -由 Console 自动生成。用户不手工配置。支持撤销和重新 enrollment。 +由 Console 在接入/部署时自动生成并注入 Worker 脚本,用户不手工配置。 +支持撤销(立即生效)与轮换(轮换后已配置域名自动重新部署)。 ## 请求签名 -所有 Node -> Console 请求必须签名,签名覆盖 method、path、timestamp、nonce、body hash,防止篡改和 replay。 +所有 Worker -> Console 请求必须签名,签名覆盖 method、path、timestamp、nonce、body hash, +Console 校验 Worker ID、timestamp 窗口、nonce 防重放、body hash、HMAC、credential 状态与 +账号归属,防止篡改、重放与跨账号访问。 -## Worker -> Node +## 隐私与日志 -Worker 使用 Node 专用 ingress secret。Worker 不应拥有 Console credential。 +- 禁止记录 Cloudflare OAuth token、Worker secret、Webhook key、Authorization header 和完整 MIME; +- reject reason 不泄露内部信息(只返回固定文案); +- 不向客户端泄露数据库内部错误。 -## 最小权限 +## 附件与大小限制 -Cloudflare OAuth 只申请业务所需权限。 +限制单文件大小、单邮件总大小、文件数量和 MIME header 大小,避免资源耗尽。 -## 日志 +## SSRF -禁止记录 Cloudflare OAuth token、Node secret、Webhook secret、Authorization header 和完整邮件正文。 - -## 附件限制 - -限制单文件大小、单邮件总大小、文件数量和 MIME header 大小,避免资源耗尽。 +Webhook endpoint 校验禁止 localhost、私网、link-local、metadata endpoint 与 DNS rebinding。 ## 数据保留 -默认 Email TTL 为 1 天,过期自动删除。 +默认 Email TTL 为 1 天,过期自动删除;nonce/oauth state/delivery 均有清理路径。 diff --git a/docs/09_CLOUDFLARE_DEPLOYMENT.md b/docs/09_CLOUDFLARE_DEPLOYMENT.md index a130f3f..dbf4e0a 100644 --- a/docs/09_CLOUDFLARE_DEPLOYMENT.md +++ b/docs/09_CLOUDFLARE_DEPLOYMENT.md @@ -1,33 +1,54 @@ -# Cloudflare 部署设计 +# Cloudflare 部署设计(v2,docs/11 §12) ## 服务 -第一版目标使用 Cloudflare Workers、Email Workers / Email Routing、D1、R2、Cron Triggers、OAuth;Queues 在需要可靠异步投递时再加入,并以当前 Cloudflare 限制为准。 +使用 Cloudflare Workers、Email Workers / Email Routing、D1、R2、Cron Triggers、OAuth; +Queues 在需要可靠异步投递时再加入,并以当前 Cloudflare 限制为准。 ## Console -Console 本身部署在 Cloudflare Workers: +Console 本身部署在 Cloudflare Workers: ```text Worker +-- Frontend Assets +-- Management API + +-- /internal/email/authorize + /internal/email/ingest(Email Worker 专用) +-- OAuth Callback +-- Cron Handler ``` -## Node +部署步骤:`bunx wrangler d1 create wmailhub` -> `wrangler r2 bucket create` -> +`wrangler d1 execute wmailhub --remote --file=./migrations/0001_init.sql`(以及 0002/0003)-> +`wrangler secret put SESSION_SECRET` -> 配置 CF_OAUTH_CLIENT_ID/SECRET/REDIRECT_URI vars。 +PUBLIC_BASE_URL 即 Email Worker 里的 CONSOLE_URL。 -Node 是独立纯后端程序,无前端。可以部署在用户服务器、云服务器或其他可访问 Console 的环境,但 Cloudflare Email Worker 必须能访问 Node ingress。 +## Email Routing 与 catch-all Worker(动态邮箱) + +动态邮箱场景把域名邮件通过 catch-all 规则交给 WmailHub Email Worker: + +```text +*@example.com + | + v +WmailHub Email Worker -> Console(authorize) + +-- accept -> ingest + +-- reject -> message.setReject() +``` + +这样即使 unknown@example.com 在 Console 中不存在,也会进入 Worker, +由 Console 判断后执行 `message.setReject()`。如果只为现有邮箱创建 literal routing, +不存在的地址可能根本不会进入 Worker,WmailHub 无法完成 SMTP 阶段拒绝。 +Console 的「域名 -> 设置 Email Workers」会自动完成:开启 Email Routing + catch-all 指向 Worker。 ## Email Worker -Email Worker 只接收 Email Event 并安全转发到 Node,不保存业务数据库。 +账号级一个 Worker(名字 wmailhub-email-),脚本由 Console 自动上传, +内含 CONSOLE_URL / WORKER_ID / WORKER_SECRET;只做 authorize + ingest + setReject, +不保存业务数据。 ## R2 -用于 raw MIME 和 attachments: - ```text emails/{yyyy}/{mm}/{dd}/{email_id}/raw.eml emails/{yyyy}/{mm}/{dd}/{email_id}/attachments/{attachment_id} @@ -35,8 +56,11 @@ emails/{yyyy}/{mm}/{dd}/{email_id}/attachments/{attachment_id} ## D1 -保存 nodes、domains、mailboxes、emails、webhooks、webhook deliveries、oauth connections。 +保存 cloudflare_accounts、domains、mailboxes、emails、email_attachments、 +webhook_deliveries、oauth_connections、worker_credentials、users、settings、api_credentials、 +nonces、oauth_states。 ## Cron -至少实现 cleanup job,建议每 15 分钟一次,具体以当前 Cloudflare 限制调整。 +至少实现 cleanup job(过期邮件 + orphan + nonce/state 清理),建议每 15 分钟一次; +webhook 重试每 1 分钟,具体以当前 Cloudflare 限制调整。 diff --git a/docs/10_IMPLEMENTATION_PLAN.md b/docs/10_IMPLEMENTATION_PLAN.md index 53c361a..1c930b4 100644 --- a/docs/10_IMPLEMENTATION_PLAN.md +++ b/docs/10_IMPLEMENTATION_PLAN.md @@ -1,61 +1,56 @@ -# ZCODE 实施计划 +# WmailHub 实施计划(v2,docs/11 §20) -## Phase 1 项目骨架 +## Phase 1 Console 重构(数据模型) -```text -WmailHub/ - console/ - node/ - worker/ - docs/ -``` +Cloudflare Account(cloudflare_accounts)、Domain、Mailbox、WorkerCredential; +迁移 0003 移除 nodes / node_credentials,emails/domains 归属改为 account_id。 -Console、Node、Worker 必须边界清晰,共享协议独立。 +## Phase 2 Authorization API -## Phase 2 Console +POST /internal/email/authorize:recipient/sender -> accept | reject(code+reason) | temporary_error; +拒绝码 DOMAIN_NOT_FOUND / DOMAIN_DISABLED / MAILBOX_NOT_FOUND / MAILBOX_DISABLED / RECEIVING_DISABLED。 -实现 Console Web、Management API、数据库、认证、Node CRUD、Domain CRUD、Mailbox CRUD、Email CRUD。 +## Phase 3 Email Worker -## Phase 3 Cloudflare OAuth +email handler + HMAC 签名 + authorize + setReject + ingest(共享逻辑见 +shared/src/email-worker-template.ts 与 worker/src)。 -实现 OAuth start、callback、state、token exchange、Account/Zone discovery、加密 token storage。 +## Phase 4 Mail Ingest API -## Phase 4 自动 Node 安装 +POST /internal/email/ingest:message/rfc822 原文直传(不转 JSON 字符串), +复验 recipient(动态邮箱),只允许 accept 后调用。 -Add Node 后完成:OAuth、Node identity、Node credential、Email Worker、Worker -> Node、Email Routing、Domain、Mailbox、Node heartbeat。正常流程禁止要求用户复制安装命令。 +## Phase 5 Cloudflare 自动配置 -## Phase 5 Node +Connect Cloudflare Account:OAuth -> Account/Zone 发现 -> 域名同步 -> +Email Worker 部署(注入 CONSOLE_URL/WORKER_ID/WORKER_SECRET)-> WorkerCredential 签发; +逐域名:Email Routing + catch-all 规则。 -实现 bootstrap、secure config、Email Worker ingress、Console egress、HMAC signing、heartbeat、有限 retry、graceful shutdown、structured logging。 +## Phase 6 邮件存储 -Node 不实现业务路由。 +D1 元数据 + R2 raw MIME/附件 + MIME 解析(降级入库)+ 大小/数量限额。 -## Phase 6 Email Pipeline +## Phase 7 Webhook / REST API / MCP / TTL -```text -Cloudflare -> Worker -> Node -> Console -> D1/R2 -``` +三级 Webhook(邮箱 -> 域名 -> 全局,冻结投递配置)、Bearer API/MCP Key、MCP 工具、 +Cron 清理(过期邮件 + orphan + nonce/state)+ Webhook 重试。 -测试 plain text、HTML、MIME、attachment、多收件人、大正文和 malformed mail。 +## Phase 8 完整测试 -## Phase 7 Webhook +1. 存在邮箱 -> 接收成功; +2. 不存在邮箱 -> message.setReject()(MAILBOX_NOT_FOUND); +3. 禁用邮箱 -> setReject(MAILBOX_DISABLED); +4. 禁用域名 -> setReject(DOMAIN_DISABLED); +5. Console 超时 -> temporary_error,不能变成 mailbox not found; +6. Console 5xx -> temporary_error; +7. ingest 失败 -> 不伪造业务拒绝; +8. Worker credential revoke/rotate -> Worker 无法访问 Console; +9. 新建邮箱后测试立即接收; +10. 删除邮箱后测试立即拒绝; +11. 多域名并发测试; +12. catch-all 地址测试。 -实现 CRUD、secret、signing、delivery、retry、timeout、失败状态和日志。 +## 最终验收 -## Phase 8 TTL - -实现 retention configuration、expires_at、Cron cleanup、R2 cleanup 和 orphan cleanup。默认 1 day。 - -## Phase 9 API / MCP - -实现 Console API,并提供 list_mailboxes、list_emails、get_email、create_mailbox、delete_mailbox、list_webhooks 等 MCP 工具。权限必须基于 Console credential。 - -## Phase 10 安全测试 - -测试 OAuth CSRF、credential revoke、replay attack、伪造 Node 请求、body 篡改、过期 timestamp、Webhook SSRF、未授权 mailbox、未授权 Cloudflare Account、超大邮件和超大附件。 - -## Phase 11 验收 - -必须完成:Cloudflare OAuth -> 自动 Node 安装 -> Domain 配置 -> Mailbox 创建 -> 真实 Email -> Email Worker -> Node -> Console -> Database/Webhook/API/MCP -> TTL 自动删除。 - -如果正常流程要求用户手工登录服务器执行安装命令,则不符合设计目标。 +运行架构必须是 `Internet -> Cloudflare Email Routing -> Email Worker -> Console(D1/R2/Webhook/API/MCP)`; +不得存在 `Email Worker -> Node -> Console`。 diff --git a/docs/11_REFACTOR_PLAN.md b/docs/11_REFACTOR_PLAN.md new file mode 100644 index 0000000..670f2d7 --- /dev/null +++ b/docs/11_REFACTOR_PLAN.md @@ -0,0 +1,676 @@ + +# WmailHub 邮件接收架构改造方案 + +> 版本:v2 +> 更新时间:2026-09-30 +> 项目:/home/bigcode/data/dev/bun/WmailHub + +## 1. 最终架构 + +WmailHub 第一版正式采用两层邮件接收架构: + + Internet + | + v + Cloudflare Email Routing + | + v + Email Worker + | + | HTTPS + HMAC + v + WmailHub Console + +删除旧方案中的 Node。 + +旧方案: + + Cloudflare -> Email Worker -> Node -> Console + +新方案: + + Cloudflare -> Email Worker -> Console + +Cloudflare Email Workers 官方 API 支持 email handler、message.from、message.to、message.raw 和 message.setReject();Workers 也可以通过 fetch() 调用外部 HTTPS API。因此 Node 在邮件链路中没有必要。 + +## 2. 核心原则 + +1. 不自建 SMTP、IMAP、POP3。 +2. 不部署邮件转发 Node。 +3. Email Worker 是 SMTP 入口。 +4. Console 是唯一业务控制中心。 +5. Worker 不保存邮箱业务数据。 +6. Worker 在 SMTP 接收阶段查询 Console。 +7. Console 明确允许时,Worker 接收邮件。 +8. Console 明确拒绝时,Worker 调用 message.setReject()。 +9. Console 超时或 5xx 不能被解释成邮箱不存在。 +10. 邮箱、域名、启用状态全部由 Console 管理。 + +## 3. 邮件接收流程 + +邮件进入 Email Worker 后,第一步不是保存邮件,而是询问 Console: + + POST /internal/email/authorize + +请求: + + { + "recipient": "test@example.com", + "sender": "sender@example.net" + } + +Console 返回三种明确状态。 + +### ACCEPT + + { + "decision": "accept" + } + +Worker 继续接收邮件,然后提交: + + POST /internal/email/ingest + +### REJECT + +例如邮箱不存在: + + { + "decision": "reject", + "code": "MAILBOX_NOT_FOUND", + "reason": "Mailbox does not exist" + } + +Worker: + + message.setReject("Mailbox does not exist"); + return; + +这是 SMTP 阶段的永久拒绝。 + +### TEMPORARY_ERROR + +例如 Console 超时、网络失败、5xx: + + { + "decision": "temporary_error" + } + +不能转换成 MAILBOX_NOT_FOUND,也不能伪造永久拒绝。 + +第一版采用安全失败策略:让 Worker 处理异常,由 Cloudflare 的邮件处理机制负责后续失败行为;后续根据实际测试补充重试策略。 + +## 4. 为什么删除 Node + +Node 原来的职责只是: + + 接收邮件 -> 转发邮件 + +这会额外产生: + +- 一次网络跳转 +- 一套部署 +- 一套认证 +- 一个故障点 +- 一套重试 +- 一套日志 +- 一套版本管理 + +Email Worker 已经能够: + +- 接收邮件 +- 读取 envelope sender / recipient +- 执行业务前置判断 +- 调用 HTTPS API +- reject +- 将原始邮件交给 Console + +因此 Node 没有独立价值。 + +## 5. Email Worker 职责 + +Worker 只负责: + +1. 接收 Cloudflare Email Routing 邮件。 +2. 读取 message.from。 +3. 读取 message.to。 +4. 调用 Console authorize API。 +5. 明确拒绝时执行 message.setReject()。 +6. 明确允许时调用 Console ingest API。 +7. 处理内部异常。 + +Worker 不负责: + +- 创建邮箱 +- 删除邮箱 +- 域名管理 +- 用户权限 +- Webhook +- API +- MCP +- D1 业务查询 +- R2 业务存储 +- TTL +- 邮箱业务规则 + +## 6. Console 职责 + +Console 是唯一业务控制中心。 + +负责: + +- Cloudflare OAuth +- Cloudflare Account +- Domain +- Mailbox +- 邮箱启用/禁用 +- Email Storage +- Webhook +- REST API +- MCP +- TTL Cleanup +- Cloudflare Email Routing 配置 +- Email Worker 部署 +- Worker Credential + +Console 是“是否允许接收邮件”的唯一事实来源。 + +## 7. Authorization API + +新增: + + POST /internal/email/authorize + +请求: + + { + "recipient": "test@example.com", + "sender": "sender@example.net" + } + +可选: + + { + "recipient": "test@example.com", + "sender": "sender@example.net", + "message_id": "" + } + +响应只能使用明确 decision: + + accept + reject + temporary_error + +reject 的 code 至少包括: + + DOMAIN_NOT_FOUND + DOMAIN_DISABLED + MAILBOX_NOT_FOUND + MAILBOX_DISABLED + RECEIVING_DISABLED + +不要只返回 exists=true/false,因为未来拒绝条件不只是邮箱是否存在。 + +## 8. Worker -> Console 安全协议 + +Authorization 和 Ingest 都必须是内部 API,不能匿名访问。 + +使用: + + HTTPS + + + Worker ID + + + timestamp + + + nonce + + + body SHA256 + + + HMAC-SHA256 + +请求头: + + Authorization: WmailHub-Worker + X-WmailHub-Timestamp: + X-WmailHub-Nonce: + X-WmailHub-Body-SHA256: + X-WmailHub-Signature: + X-WmailHub-Protocol: 1 + +Canonical String: + + METHOD + "\n" + + PATH + "\n" + + TIMESTAMP + "\n" + + NONCE + "\n" + + BODY_SHA256 + +Console 必须: + +1. 校验 Worker ID。 +2. 校验 timestamp。 +3. 防止 nonce 重放。 +4. 校验 body hash。 +5. 校验 HMAC。 +6. 校验 Worker 状态。 +7. 校验 Worker 所属 Cloudflare Account。 + +## 9. Mail Ingest API + +新增: + + POST /internal/email/ingest + +只有 authorize 返回 accept 后才能调用。 + +建议: + + Content-Type: message/rfc822 + +HTTP body 直接传递原始 MIME,不要把整个 MIME 转成 JSON 字符串。 + +Console 收到后: + +1. 验证 Worker 身份。 +2. 验证 recipient。 +3. 创建 Email metadata。 +4. 将 raw MIME 写入 R2。 +5. 保存附件信息。 +6. 触发 Webhook。 +7. 返回成功。 + +## 10. 两阶段模型 + +第一阶段: + + Email Worker + | + v + /authorize + | + +-- accept + | + +-- reject + | + +-- temporary_error + +这一阶段只回答: + + “这封邮件是否应该由 WmailHub 接收?” + +第二阶段: + + Email Worker + | + v + /ingest + | + +-- D1 + +-- R2 + +-- Webhook + +这样 SMTP 判断和邮件存储完全分离。 + +## 11. 动态邮箱 + +Console 创建: + + support@example.com + +下一封邮件: + + Worker + -> authorize + -> accept + -> ingest + +删除或禁用后: + + Worker + -> authorize + -> reject + -> message.setReject() + +Worker 不需要保存 mailbox list,也不需要 Node 同步配置。 + +## 12. Cloudflare Email Routing + +动态邮箱场景推荐把域名邮件通过 catch-all Worker 交给 WmailHub。 + +例如: + + *@example.com + | + v + WmailHub Email Worker + +这样即使: + + unknown@example.com + +在 Console 中不存在,也能进入 Worker,由 Console 判断后执行 message.setReject()。 + +如果只为现有邮箱创建 literal routing,那么不存在的地址可能根本不会进入 Worker,因此无法由 WmailHub 自己完成 SMTP reject。 + +## 13. 数据模型 + +删除 Node 作为邮件链路中的核心模型。 + +保留: + +### CloudflareAccount + +- id +- account_id +- oauth_connection_id +- status + +### Domain + +- id +- cloudflare_account_id +- name +- status + +### Mailbox + +- id +- domain_id +- address +- status +- created_at +- updated_at + +### Email + +- id +- mailbox_id +- message_id +- sender +- recipient +- subject +- received_at +- expires_at +- raw_object_key + +### EmailAttachment + +保留。 + +### Webhook + +保留。 + +### WebhookDelivery + +保留。 + +### WorkerCredential + +用于 Worker 与 Console 内部认证: + +- id +- cloudflare_account_id +- worker_id +- secret +- status +- created_at +- revoked_at + +## 14. Cloudflare OAuth + +旧的“Add Node”概念删除。 + +改为: + + Connect Cloudflare Account + +流程: + + Console + | + v + Cloudflare OAuth + | + v + 用户授权 Account + | + v + Console + | + +-- 保存 Account + +-- 配置 Email Routing + +-- 部署 Email Worker + +-- 配置 Worker Secret + +-- 配置 Domain + +用户不需要执行安装命令。 + +## 15. 故障策略 + +### Console 正常 + + authorize -> accept/reject + +### Console 明确 reject + + message.setReject() + +### Console 5xx + +不能当作邮箱不存在。 + +### Console timeout + +不能当作邮箱不存在。 + +### Ingest 失败 + +不能伪造业务 reject。 + +必须记录处理失败,并按照 Cloudflare Email Worker 的实际投递/重试行为处理。 + +## 16. 不实现 NDR + +第一版明确不采用: + + 250 OK + | + v + 异步发现邮箱不存在 + | + v + WmailHub 自己生成 NDR + +原因: + +- 增加 SMTP outbound +- 增加 DSN/NDR 实现 +- 增加 SPF/DKIM/DMARC 发送问题 +- 增加 bounce loop 风险 +- 增加 backscatter 风险 +- 增加系统复杂度 + +本版本直接使用 Cloudflare Email Worker 的 message.setReject()。 + +## 17. 性能 + +第一版每封邮件: + + 1 次 /authorize + + + 1 次 /ingest + +这是有意选择的。 + +暂不加入: + +- KV mailbox cache +- Durable Object +- mailbox synchronization +- Node queue +- 本地 mailbox database + +未来如果 authorize 成为瓶颈,再考虑缓存。 + +但缓存必须接受邮箱创建、删除和停用存在传播延迟的可能性。 + +## 18. 安全 + +必须防止公网直接调用: + + /internal/email/authorize + /internal/email/ingest + +必须: + +- HTTPS +- HMAC +- timestamp +- nonce +- body hash +- Worker credential revoke +- 防重放 +- 不记录完整 MIME +- 不泄露数据库内部错误 +- reject reason 不暴露敏感信息 + +## 19. 文档同步修改 + +以下旧文档必须同步修改: + + 01_ARCHITECTURE.md + 02_CLOUDFLARE_OAUTH.md + 03_NODE.md + 04_NODE_PROTOCOL.md + 05_DATA_MODEL.md + 06_EMAIL_LIFECYCLE.md + 07_WEBHOOK_API_MCP.md + 08_SECURITY.md + 09_CLOUDFLARE_DEPLOYMENT.md + 10_IMPLEMENTATION_PLAN.md + +具体: + +- 03_NODE.md:删除 Node 邮件转发设计。 +- 04_NODE_PROTOCOL.md:改成 Worker-Console Protocol。 +- 02_CLOUDFLARE_OAUTH.md:Add Node 改成 Connect Cloudflare Account + 自动部署 Worker。 +- 05_DATA_MODEL.md:删除 Node 邮件转发相关模型。 +- 06_EMAIL_LIFECYCLE.md:改成 Worker -> Console。 +- 09_CLOUDFLARE_DEPLOYMENT.md:增加 catch-all Worker routing。 +- 10_IMPLEMENTATION_PLAN.md:重新排列实施阶段。 + +## 20. 实施顺序 + +### Phase 1 + +重构 Console: + +- Cloudflare Account +- Domain +- Mailbox +- WorkerCredential + +### Phase 2 + +实现: + + POST /internal/email/authorize + +### Phase 3 + +实现 Email Worker: + +- email handler +- HMAC +- authorize +- setReject +- ingest + +### Phase 4 + +实现: + + POST /internal/email/ingest + +### Phase 5 + +Cloudflare 自动配置: + +- Email Routing +- catch-all Worker +- Worker deployment +- Worker secrets + +### Phase 6 + +邮件存储: + +- D1 +- R2 +- MIME +- Attachment + +### Phase 7 + +- Webhook +- REST API +- MCP +- TTL cleanup + +### Phase 8 + +完整测试: + +1. 存在邮箱 -> 接收成功。 +2. 不存在邮箱 -> message.setReject()。 +3. 禁用邮箱 -> message.setReject()。 +4. 禁用域名 -> message.setReject()。 +5. Console timeout -> 不能变成 mailbox not found。 +6. Console 5xx -> 不能变成 mailbox not found。 +7. ingest 失败 -> 不伪造业务拒绝。 +8. Worker credential revoke -> Worker 无法访问 Console。 +9. 新建邮箱后测试立即接收。 +10. 删除邮箱后测试立即拒绝。 +11. 多域名并发测试。 +12. catch-all 地址测试。 + +## 21. 最终验收标准 + +最终运行架构必须是: + + Internet + | + v + Cloudflare Email Routing + | + v + Email Worker + | + v + WmailHub Console + | + +-- D1 + +-- R2 + +-- Webhook + +-- API + +-- MCP + +不得存在: + + Email Worker -> Node -> Console + +WmailHub 第一版的邮件入口、SMTP 决策和业务控制关系正式确定为: + + Cloudflare + -> Email Worker + -> Console + +其中: + +- Worker = 邮件入口 + SMTP reject 执行者 +- Console = 邮箱规则 + 邮件业务控制中心 +- D1/R2 = Console 的数据层 +- Node = 从 WmailHub 邮件架构中移除 diff --git a/migrations/0003_mail_link_v2.sql b/migrations/0003_mail_link_v2.sql new file mode 100644 index 0000000..d4d4e7e --- /dev/null +++ b/migrations/0003_mail_link_v2.sql @@ -0,0 +1,51 @@ +-- v2.0.0:邮件接收架构改造(docs/11) +-- Cloudflare -> Email Worker -> Console;Node 从邮件链路中移除。 +-- - nodes -> cloudflare_accounts(账号 = 已连接的 Cloudflare 账户,不再承载 Node 转发平面) +-- - node_credentials -> worker_credentials(Email Worker 与 Console 的内部认证凭据) +-- - nonces/oauth_states/domains/emails 的 node_id 语义改为 account_id(Console 账号) + +-- nonce 防重放:归属改为账号 +ALTER TABLE nonces RENAME COLUMN node_id TO account_id; + +-- OAuth state 绑定账号 +ALTER TABLE oauth_states RENAME COLUMN node_id TO account_id; + +-- 邮件归属账号 +ALTER TABLE emails RENAME COLUMN node_id TO account_id; + +-- 域名归属账号(domains.node_id 存的是 Console 账号 id) +ALTER TABLE domains RENAME COLUMN node_id TO account_id; + +-- 删除 Node 凭据表 +DROP TABLE IF EXISTS node_credentials; + +-- 账号表更名并去除 Node 专属列 +ALTER TABLE nodes RENAME TO cloudflare_accounts; +ALTER TABLE cloudflare_accounts DROP COLUMN ingress_url; +ALTER TABLE cloudflare_accounts DROP COLUMN ingress_secret_enc; +ALTER TABLE cloudflare_accounts DROP COLUMN enrollment_token; +ALTER TABLE cloudflare_accounts DROP COLUMN enrollment_expires_at; +ALTER TABLE cloudflare_accounts DROP COLUMN credential_id; +ALTER TABLE cloudflare_accounts DROP COLUMN version; +ALTER TABLE cloudflare_accounts DROP COLUMN last_seen_at; + +-- 账号级 Email Worker 脚本名(worker_id 同时是 Worker -> Console 的身份 ID) +ALTER TABLE cloudflare_accounts ADD COLUMN worker_id TEXT; + +-- 历史状态归一:心跳概念移除,online/offline 统一为 active +UPDATE cloudflare_accounts SET status = 'active' WHERE status IN ('online', 'offline'); + +-- Email Worker -> Console 内部凭据:secret_hash 为摘要,secret_enc 为 AES-GCM 密文(HMAC 验签需要原文) +CREATE TABLE IF NOT EXISTS worker_credentials ( + id TEXT PRIMARY KEY, + cloudflare_account_id TEXT NOT NULL, + worker_id TEXT NOT NULL, -- Email Worker 脚本名,即请求方身份 + secret_hash TEXT NOT NULL, + secret_enc TEXT NOT NULL, + secret_last4 TEXT NOT NULL, -- 展示用尾 4 位(明文 secret 不入库) + status TEXT NOT NULL DEFAULT 'active', -- active | revoked + created_at TEXT NOT NULL, + revoked_at TEXT +); +CREATE INDEX IF NOT EXISTS idx_worker_credentials_account ON worker_credentials(cloudflare_account_id); +CREATE INDEX IF NOT EXISTS idx_worker_credentials_worker ON worker_credentials(worker_id); diff --git a/node/src/main.ts b/node/src/main.ts deleted file mode 100644 index 664f0d6..0000000 --- a/node/src/main.ts +++ /dev/null @@ -1,247 +0,0 @@ -/** - * WmailHub Node —— 纯后端邮件转发节点(docs/03、docs/05 Phase 5)。 - * - * 职责(仅此而已): - * Email Worker --ingress secret--> Node --HMAC 签名--> Console - * - 校验 Worker 请求(ingress secret) - * - 转发到 Console(有限重试:网络错误/5xx 最多 3 次;401/403 不重试) - * - 心跳上报(60s,可配置) - * - bootstrap:支持 enrollment token 自动换取身份,secret 落盘 0600 - * - 结构化 JSON 日志、优雅退出 - * - * 配置来源(优先级):环境变量 > config.json(bootstrap 写入) - * WMAILHUB_CONSOLE_URL Console 地址 - * WMAILHUB_NODE_ID 节点 ID - * WMAILHUB_NODE_SECRET node credential secret - * WMAILHUB_INGRESS_SECRET Worker -> Node 共享密钥 - * WMAILHUB_ENROLLMENT_TOKEN 首次启动用 token 换身份(可选) - * WMAILHUB_PORT 监听端口(默认 18788) - */ - -import type { Server } from "bun"; - -import path from "node:path"; -import { - signNodeRequest, - INGRESS_SECRET_HEADER, - NODE_HEARTBEAT_INTERVAL_MS, -} from "../../shared/src/index.ts"; -import { APP_VERSION } from "./version.ts"; - -/** # 成员变量: Node 运行配置 **/ -interface NodeConfig { - consoleUrl: string; - nodeId: string; - nodeSecret: string; - ingressSecret: string; - port: number; -} - -/** # 成员变量: config.json 落盘位置 **/ -const CONFIG_FILE = path.resolve(import.meta.dir, "../config.json"); - -/** # 函数 - * 结构化 JSON 日志 - */ -function log(lvl: "info" | "warn" | "error", msg: string, extra: Record = {}) { - console.log(JSON.stringify({ t: new Date().toISOString(), lvl, msg, ...extra })); -} - -/** # 函数 - * 加载配置:环境变量优先,其次 config.json(enrollment 产物) - */ -async function loadConfig(): Promise { - let file: Partial = {}; - try { - file = (await Bun.file(CONFIG_FILE).json()) as Partial; - } catch { - // 无 config.json,纯环境变量模式 - } - const env = process.env; - const consoleUrl = env.WMAILHUB_CONSOLE_URL ?? file.consoleUrl; - const ingressSecret = env.WMAILHUB_INGRESS_SECRET ?? file.ingressSecret; - let nodeId = env.WMAILHUB_NODE_ID ?? file.nodeId; - let nodeSecret = env.WMAILHUB_NODE_SECRET ?? file.nodeSecret; - - if (!consoleUrl || !ingressSecret) { - console.error("missing WMAILHUB_CONSOLE_URL / WMAILHUB_INGRESS_SECRET"); - process.exit(1); - } - - // bootstrap enrollment:有 token 而无身份时自动换取并落盘(0600) - const enrollmentToken = env.WMAILHUB_ENROLLMENT_TOKEN ?? ""; - if ((!nodeId || !nodeSecret) && enrollmentToken) { - log("info", "enrolling with console"); - const resp = await fetch(new URL("/api/internal/enroll", consoleUrl), { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ enrollment_token: enrollmentToken }), - }); - if (!resp.ok) { - log("error", "enroll failed", { status: resp.status }); - process.exit(1); - } - const data = (await resp.json()) as { - node_id: string; - credential_id: string; - credential_secret: string; - ingress_secret: string; - }; - nodeId = data.node_id; - nodeSecret = data.credential_secret; - await Bun.write(CONFIG_FILE, JSON.stringify({ consoleUrl, nodeId, nodeSecret, ingressSecret: ingressSecret }), { - mode: 0o600, - }); - log("info", "enrolled, secure config written", { node_id: nodeId, credential_id: data.credential_id }); - } - - if (!nodeId || !nodeSecret) { - console.error("missing WMAILHUB_NODE_ID / WMAILHUB_NODE_SECRET (or WMAILHUB_ENROLLMENT_TOKEN)"); - process.exit(1); - } - - return { - consoleUrl: consoleUrl.replace(/\/$/, ""), - nodeId, - nodeSecret, - ingressSecret, - port: Number(env.WMAILHUB_PORT ?? 18788), - }; -} - -/** # 函数 - * 转发邮件到 Console:构造 HMAC 签名请求;网络错误/5xx/429 有限重试,401/403 立即放弃 - */ -async function forwardToConsole(cfg: NodeConfig, raw: Uint8Array, mailFrom: string, rcptTo: string): Promise { - const url = `${cfg.consoleUrl}/api/internal/nodes/${cfg.nodeId}/emails`; - const maxAttempts = 3; - let lastError = ""; - for (let attempt = 1; attempt <= maxAttempts; attempt++) { - const headers = await signNodeRequest(cfg.nodeSecret, cfg.nodeId, "POST", url, raw); - headers["Content-Type"] = "message/rfc822"; - headers["X-WmailHub-Mail-From"] = mailFrom; - headers["X-WmailHub-Rcpt-To"] = rcptTo; - try { - const resp = await fetch(url, { - method: "POST", - headers, - body: raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength) as ArrayBuffer, - }); - if (resp.ok) return resp; - if (resp.status === 401 || resp.status === 403) { - // 凭据问题:重试无意义(docs/04:不得无限重试) - return resp; - } - lastError = `console responded ${resp.status}`; - } catch (err) { - lastError = err instanceof Error ? err.message : String(err); - } - if (attempt < maxAttempts) { - const delayMs = 500 * 2 ** (attempt - 1); - log("warn", "forward retry scheduled", { attempt, delayMs, error: lastError }); - await new Promise((r) => setTimeout(r, delayMs)); - } - } - return new Response(JSON.stringify({ error: lastError }), { status: 503 }); -} - -/** # 函数 - * 发送一次心跳;401/403 只告警不退出 - */ -async function heartbeat(cfg: NodeConfig): Promise { - const url = `${cfg.consoleUrl}/api/internal/nodes/${cfg.nodeId}/heartbeat`; - const body = new TextEncoder().encode(JSON.stringify({ version: APP_VERSION })); - const headers = await signNodeRequest(cfg.nodeSecret, cfg.nodeId, "POST", url, body); - headers["Content-Type"] = "application/json"; - try { - const resp = await fetch(url, { method: "POST", headers, body }); - if (!resp.ok) { - log("warn", "heartbeat rejected", { status: resp.status }); - } else { - log("info", "heartbeat ok"); - } - } catch (err) { - log("warn", "heartbeat failed", { error: err instanceof Error ? err.message : String(err) }); - } -} - -/** # 函数 - * 主入口:加载配置 -> 起服务 -> 定时心跳 -> 优雅退出 - */ -async function main() { - const cfg = await loadConfig(); - let shuttingDown = false; - let inflight = 0; - - const server: Server = Bun.serve({ - port: cfg.port, - hostname: "0.0.0.0", - idleTimeout: 30, - async fetch(request) { - const url = new URL(request.url); - if (url.pathname === "/__health") { - return Response.json({ ok: true, node_id: cfg.nodeId, version: APP_VERSION }); - } - // 邮件 ingress:仅接受 Email Worker 的签名 secret 请求 - if (request.method === "POST" && url.pathname === "/ingress/email") { - if (request.headers.get(INGRESS_SECRET_HEADER) !== cfg.ingressSecret) { - log("warn", "ingress rejected: bad secret", { ip: request.headers.get("x-forwarded-for") ?? "" }); - return Response.json({ error: "forbidden" }, { status: 403 }); - } - const raw = new Uint8Array(await request.arrayBuffer()); - if (raw.byteLength === 0) { - return Response.json({ error: "empty body" }, { status: 400 }); - } - const mailFrom = request.headers.get("X-WmailHub-Mail-From") ?? ""; - const rcptTo = request.headers.get("X-WmailHub-Rcpt-To") ?? ""; - inflight++; - try { - const resp = await forwardToConsole(cfg, raw, mailFrom, rcptTo); - const body = (await resp.json().catch(() => ({}))) as Record; - log(resp.ok ? "info" : "error", resp.ok ? "email forwarded" : "forward failed", { - bytes: raw.byteLength, - status: resp.status, - email_id: body.id, - }); - return Response.json(body, { status: resp.status }); - } finally { - inflight--; - } - } - return Response.json({ error: "not found" }, { status: 404 }); - }, - }); - - log("info", "node started", { - node_id: cfg.nodeId, - port: cfg.port, - console: cfg.consoleUrl, - version: APP_VERSION, - }); - - // 心跳(启动即一次) - void heartbeat(cfg); - const heartbeatTimer = setInterval(() => void heartbeat(cfg), NODE_HEARTBEAT_INTERVAL_MS); - - /** 优雅退出:停止接新请求,等在途转发完成 */ - async function shutdown(signal: string) { - if (shuttingDown) return; - shuttingDown = true; - log("info", "node shutting down", { signal, inflight }); - clearInterval(heartbeatTimer); - server.stop(false); // 停止接受新连接,保持既有 - const deadline = Date.now() + 10_000; - while (inflight > 0 && Date.now() < deadline) { - await new Promise((r) => setTimeout(r, 100)); - } - server.stop(true); - process.exit(0); - } - process.on("SIGINT", () => void shutdown("SIGINT")); - process.on("SIGTERM", () => void shutdown("SIGTERM")); -} - -main().catch((err) => { - log("error", "node crashed", { error: err instanceof Error ? err.stack : String(err) }); - process.exit(1); -}); diff --git a/node/src/version.ts b/node/src/version.ts deleted file mode 100644 index f5c4309..0000000 --- a/node/src/version.ts +++ /dev/null @@ -1,4 +0,0 @@ -/** Node 版本:与项目根 package.json 一致(全局版本规范,禁止手写常量) */ -import pkg from "../../package.json"; - -export const APP_VERSION: string = pkg.version; diff --git a/package.json b/package.json index d2857a2..bf81825 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "wmailhub", - "version": "1.1.0", - "description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console + Node + Email Worker)", + "version": "2.0.0", + "description": "WmailHub - 基于 Cloudflare Email Workers 的轻量邮件接收与转发平台(Console 直连 Email Worker)", "private": true, "type": "module", "scripts": { diff --git a/scripts/dev.ts b/scripts/dev.ts index f31549e..d25d595 100644 --- a/scripts/dev.ts +++ b/scripts/dev.ts @@ -1,26 +1,25 @@ /** - * 本地开发一键启动:console + node + email worker。 + * 本地开发一键启动:console + email worker(docs/11,Node 已移除)。 * bun run dev # 常规启动(复用既有数据) - * bun run dev -- --reset # 清空本地数据后全新安装 + * bun run dev -- --reset # 清空本地数据后全新接入 * * 流程: * 1. 构建前端(vite build) * 2. 启动 Console(:18787,自动迁移 + admin/admin 种子) - * 3. 确保存在本地账号(无则 OAuth sim 自动创建账号 + 同步域名),并逐域名配置 Email Workers - * 4. 启动 Node(:18788,enrollment 换取身份,config.json 0600 落盘) - * 5. 启动 Email Worker(:18789,转发到 Node) - * 6. Ctrl-C 一起退出 + * 3. 确保存在账号(无则 OAuth sim 自动创建账号),并逐域名配置 Email Workers + * (域名 setup 会部署账号级 Email Worker + 签发 WorkerCredential) + * 4. 启动 Email Worker(:18789,authorize/ingest 直连 Console) + * 5. Ctrl-C 一起退出 */ const ROOT = new URL("..", import.meta.url).pathname; const CONSOLE_PORT = 18787; -const NODE_PORT = 18788; const WORKER_PORT = 18789; const BASE = `http://127.0.0.1:${CONSOLE_PORT}`; const reset = process.argv.includes("--reset"); if (reset) { - await Bun.$`rm -rf ${ROOT}console/data ${ROOT}node/config.json`.quiet(); + await Bun.$`rm -rf ${ROOT}console/data`.quiet(); console.log("[dev] 本地数据已重置"); } @@ -79,24 +78,16 @@ 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. 确保本地账号存在(无则走 OAuth 自动创建);拿到 ingress secret 与 enrollment token +// 3. 确保账号存在(无则走 OAuth 自动创建) 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; +let accountId: string; if (existing.length > 0) { - nodeId = existing[0].id; - 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/accounts/${nodeId}/rotate`, { method: "POST", headers: authHeaders }) - ).json()) as { enrollment_token: string }; - enrollmentToken = rotated.enrollment_token; - console.log(`[dev] 复用既有账号 ${nodeId}`); + accountId = existing[0].id; + console.log(`[dev] 复用既有账号 ${accountId}`); } else { - // 添加账号:OAuth start 不带 node_id,服务端自动创建账号 + // 添加账号:OAuth start 自动创建账号,授权后自动接入 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`, { @@ -104,21 +95,16 @@ if (existing.length > 0) { headers: authHeaders, body: JSON.stringify({ state }), }); - const approveRes = (await approve.json()) as { ok: boolean; error?: string; node_id: string }; + const approveRes = (await approve.json()) as { ok: boolean; error?: string; account_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} 并同步域名`); + accountId = approveRes.account_id; + console.log(`[dev] OAuth(sim)已创建账号 ${accountId} 并同步域名`); } -// 4. 域名同步后逐域名配置 Email Workers(sim 模式记录;幂等) +// 4. 域名同步后逐域名配置 Email Workers(部署 Worker + Routing + catch-all;幂等) 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 domains = ((await domainsResp.json()) as { items: { id: string; name: string; account_id: string | null; status: string }[] }).items; +for (const d of domains.filter((x) => x.account_id === accountId && x.status !== "active")) { const setup = (await ( await fetch(`${BASE}/api/domains/${d.id}/setup`, { method: "POST", headers: authHeaders }) ).json()) as { ok: boolean; error?: string }; @@ -126,21 +112,14 @@ for (const d of domains.filter((x) => x.status !== "active")) { console.log(`[dev] 域名 ${d.name} Email Workers 已配置`); } -// 5. Node(首次用 enrollment token 换身份;之后 config.json 已有身份) -const nodeEnv: Record = { - WMAILHUB_CONSOLE_URL: BASE, - WMAILHUB_INGRESS_SECRET: ingressSecret, - WMAILHUB_PORT: String(NODE_PORT), -}; -if (enrollmentToken) nodeEnv.WMAILHUB_ENROLLMENT_TOKEN = enrollmentToken; -spawn("node", ["node/src/main.ts"], ROOT, nodeEnv); -await waitReady(`http://127.0.0.1:${NODE_PORT}/__health`, "node"); -console.log("[dev] node ready"); - -// 6. Email Worker +// 5. Email Worker(authorize/ingest 直连 Console;凭据来自 WorkerCredential) +const secretResp = (await ( + await fetch(`${BASE}/api/accounts/${accountId}/worker-secret`, { headers: authHeaders }) +).json()) as { worker_id: string; secret: string }; spawn("worker", ["worker/src/local.ts"], ROOT, { - NODE_INGRESS_URL: `http://127.0.0.1:${NODE_PORT}/ingress/email`, - NODE_INGRESS_SECRET: ingressSecret, + CONSOLE_URL: BASE, + WORKER_ID: secretResp.worker_id, + WORKER_SECRET: secretResp.secret, WMAILHUB_WORKER_PORT: String(WORKER_PORT), }); await waitReady(`http://127.0.0.1:${WORKER_PORT}/__health`, "email worker"); @@ -149,10 +128,9 @@ console.log("[dev] email worker ready"); console.log(""); console.log("======== WmailHub 本地环境已就绪 ========"); console.log(` Console : ${BASE} (admin / admin)`); -console.log(` Node : http://127.0.0.1:${NODE_PORT}/__health`); -console.log(` Email Worker: http://127.0.0.1:${WORKER_PORT}/__health`); +console.log(` Email Worker: http://127.0.0.1:${WORKER_PORT}/__health (worker_id=${secretResp.worker_id})`); console.log(" 发测试邮件 : curl -X POST http://127.0.0.1:18789/__email_test \\"); -console.log(" -H 'X-WmailHub-Ingress-Secret: ' \\"); +console.log(" -H 'X-WmailHub-Test-Secret: ' \\"); console.log(" -H 'X-WmailHub-Test-To: hello@example.test' --data-binary @mail.eml"); console.log("=========================================="); diff --git a/shared/src/constants.ts b/shared/src/constants.ts index 3cc0440..c6c7d75 100644 --- a/shared/src/constants.ts +++ b/shared/src/constants.ts @@ -32,8 +32,11 @@ export const WEBHOOK_HEADERS = { signature: "X-WmailHub-Signature", } as const; -/** # 成员变量: Worker -> Node ingress 共享密钥请求头 **/ -export const INGRESS_SECRET_HEADER = "X-WmailHub-Ingress-Secret"; +/** # 成员变量: Email Worker 本地测试入口请求头(Worker secret 验证) **/ +export const WORKER_TEST_SECRET_HEADER = "X-WmailHub-Test-Secret"; -/** # 成员变量: Node 心跳间隔(毫秒) **/ -export const NODE_HEARTBEAT_INTERVAL_MS = 60_000; +/** # 成员变量: Worker -> Console 信封传递请求头 **/ +export const WORKER_ENVELOPE_HEADERS = { + recipient: "X-WmailHub-Recipient", + sender: "X-WmailHub-Sender", +} as const; diff --git a/shared/src/email-worker-template.ts b/shared/src/email-worker-template.ts index 7db1f1d..d43f619 100644 --- a/shared/src/email-worker-template.ts +++ b/shared/src/email-worker-template.ts @@ -1,37 +1,50 @@ /** - * 部署到 Cloudflare 的 Email Worker 脚本模板。 + * 部署到 Cloudflare 的 Email Worker 脚本模板(docs/11)。 * - * Console 在自动安装 Node 时,通过 Cloudflare API 把该脚本上传为 - * Email Worker,并用占位符替换注入 Node ingress 地址与 ingress secret。 - * worker/(本地开发版)与该模板保持同一转发逻辑。 + * Console 在「Connect Cloudflare Account」/ 域名配置 Email Workers 时,通过 Cloudflare API + * 把该脚本上传为账号级 Email Worker,并以占位符注入三组配置: + * __CONSOLE_URL__ Console 对外地址(Worker -> Console 的目标) + * __WORKER_ID__ Worker 身份(与 WorkerCredential.worker_id 一致) + * __WORKER_SECRET__ Worker credential secret(HMAC 签名密钥) + * + * 邮件流程:authorize(接受/拒绝/临时错误) -> accept 时 ingest 原始 MIME -> reject 时 setReject。 + * worker/(本地开发版)与该模板保持同一逻辑。 */ export const EMAIL_WORKER_SCRIPT = String.raw`// WmailHub Email Worker (auto-generated by Console provisioning) -// Cloudflare Email Routing -> Email Worker -> Node ingress -const INGRESS_URL = "__NODE_INGRESS_URL__"; -const INGRESS_SECRET = "__NODE_INGRESS_SECRET__"; +// Cloudflare Email Routing -> Email Worker -> WmailHub Console(docs/11) +const CONSOLE_URL = "__CONSOLE_URL__"; +const WORKER_ID = "__WORKER_ID__"; +const WORKER_SECRET = "__WORKER_SECRET__"; export default { async email(message, env, ctx) { - const raw = await new Response(message.raw).arrayBuffer(); - await forward(raw, message.from, message.to); + const result = await handleEmail(env, message); + if (result.action === "rejected") { + message.setReject(result.reason || "rejected by WmailHub"); + return; + } + if (result.action === "temporary_error") { + // 不能伪造永久拒绝;抛出异常交给 Cloudflare 邮件机制处理后续重试 + throw new Error(result.reason || "wmailhub console temporary error"); + } }, async fetch(request) { const url = new URL(request.url); if (url.pathname === "/__health") { return Response.json({ ok: true, service: "wmailhub-email-worker" }); } - // 本地/联调测试入口:与 email handler 走同一条转发链路 + // 本地/联调测试入口:与 email handler 走同一条两阶段链路 if (request.method === "POST" && url.pathname === "/__email_test") { - if (request.headers.get("X-WmailHub-Ingress-Secret") !== INGRESS_SECRET) { + if (request.headers.get("X-WmailHub-Test-Secret") !== WORKER_SECRET) { return Response.json({ error: "forbidden" }, { status: 403 }); } const from = request.headers.get("X-WmailHub-Test-From") || "sender@example.org"; const to = request.headers.get("X-WmailHub-Test-To") || "hello@example.com"; const raw = await request.arrayBuffer(); try { - await forward(raw, from, to); - return Response.json({ ok: true }); + const result = await handleEmail(env, { from, to, raw: new Response(raw).body }); + return Response.json(result, { status: result.action === "accepted" ? 200 : 502 }); } catch (err) { return Response.json({ error: String(err) }, { status: 502 }); } @@ -40,31 +53,131 @@ export default { }, }; -async function forward(raw, from, to) { - const resp = await fetch(INGRESS_URL, { - method: "POST", - headers: { - "Content-Type": "message/rfc822", - "X-WmailHub-Ingress-Secret": INGRESS_SECRET, - "X-WmailHub-Mail-From": from, - "X-WmailHub-Rcpt-To": to, - }, - body: raw, - }); - if (!resp.ok) { - throw new Error("node ingress responded " + resp.status); +async function hmac(secret, message) { + const key = await crypto.subtle.importKey( + "raw", + new TextEncoder().encode(secret), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(message)); + return [...new Uint8Array(sig)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +async function sha256Hex(data) { + const digest = await crypto.subtle.digest("SHA-256", data); + return [...new Uint8Array(digest)].map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +function randomNonce() { + const bytes = crypto.getRandomValues(new Uint8Array(16)); + return [...bytes].map((b) => b.toString(16).padStart(2, "0")).join(""); +} + +// METHOD\nPATH\nTIMESTAMP\nNONCE\nBODY_SHA256 +async function signedHeaders(method, path, body) { + const timestamp = String(Math.floor(Date.now() / 1000)); + const nonce = randomNonce(); + const bh = await sha256Hex(body); + const signature = await hmac(WORKER_SECRET, [method, path, timestamp, nonce, bh].join("\n")); + return { + "Authorization": "WmailHub-Worker " + WORKER_ID, + "X-WmailHub-Timestamp": timestamp, + "X-WmailHub-Nonce": nonce, + "X-WmailHub-Body-SHA256": bh, + "X-WmailHub-Signature": signature, + "X-WmailHub-Protocol": "1", + }; +} + +// 第一阶段:询问 Console 是否接收该邮件(SMTP 决策) +async function authorize(env, recipient, sender) { + const body = new TextEncoder().encode(JSON.stringify({ recipient, sender })); + const headers = await signedHeaders("POST", "/internal/email/authorize", body); + let resp; + try { + resp = await fetch(CONSOLE_URL + "/internal/email/authorize", { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body, + }); + } catch (err) { + // Console 不可达:临时错误,不能当作邮箱不存在 + return { action: "temporary_error", reason: "console unreachable: " + err }; } + if (resp.status >= 500) return { action: "temporary_error", reason: "console responded " + resp.status }; + if (!resp.ok) return { action: "temporary_error", reason: "console responded " + resp.status }; + let data; + try { + data = await resp.json(); + } catch { + return { action: "temporary_error", reason: "console returned non-json" }; + } + if (data.decision === "accept") return { action: "accepted" }; + if (data.decision === "reject") { + return { action: "rejected", reason: data.reason || data.code || "rejected by WmailHub", code: data.code }; + } + return { action: "temporary_error", reason: "console decision: " + (data.decision || "unknown") }; +} + +// 第二阶段:把原始 MIME 交给 Console 入库(D1 + R2 + Webhook) +async function ingest(env, raw, recipient, sender) { + const body = new Uint8Array(raw); + const headers = await signedHeaders("POST", "/internal/email/ingest", body); + let resp; + try { + resp = await fetch(CONSOLE_URL + "/internal/email/ingest", { + method: "POST", + headers: { + ...headers, + "Content-Type": "message/rfc822", + "X-WmailHub-Recipient": recipient, + "X-WmailHub-Sender": sender, + }, + body, + }); + } catch (err) { + console.error(JSON.stringify({ service: "wmailhub-email-worker", msg: "ingest failed", error: String(err) })); + return { action: "temporary_error", reason: "ingest unreachable: " + err }; + } + if (!resp.ok) { + const text = await resp.text(); + // ingest 失败不伪造业务拒绝;记录并按 Cloudflare 的重试行为处理 + console.error(JSON.stringify({ service: "wmailhub-email-worker", msg: "ingest failed", status: resp.status, body: text.slice(0, 300) })); + return { action: "temporary_error", reason: "ingest responded " + resp.status }; + } + let data = {}; + try { + data = await resp.json(); + } catch { + // ignore + } + return { action: "accepted", email_id: data.id, mailbox_id: data.mailbox_id }; +} + +// 完整两阶段处理(与部署模板共同逻辑) +async function handleEmail(env, message) { + const recipient = message.to; + const sender = message.from; + const decision = await authorize(env, recipient, sender); + if (decision.action !== "accepted") return decision; + const raw = await new Response(message.raw).arrayBuffer(); + return await ingest(env, raw, recipient, sender); } `; /** # 函数 - * 用实际 Node ingress 配置渲染 Email Worker 脚本 - * 参数: ingressUrl - Node 的 Email Worker ingress 完整 URL;ingressSecret - Worker -> Node 共享密钥 + * 用实际 Console 地址与 Worker 凭据渲染 Email Worker 脚本 + * 参数: consoleUrl - Console 对外地址;workerId - Worker 身份;workerSecret - credential secret * 返回值: 可直接上传 Cloudflare 的脚本字符串 */ -export function renderEmailWorkerScript(ingressUrl: string, ingressSecret: string): string { - return EMAIL_WORKER_SCRIPT.replace("__NODE_INGRESS_URL__", ingressUrl).replace( - "__NODE_INGRESS_SECRET__", - ingressSecret, - ); +export function renderEmailWorkerScript( + consoleUrl: string, + workerId: string, + workerSecret: string, +): string { + return EMAIL_WORKER_SCRIPT.replace("__CONSOLE_URL__", consoleUrl) + .replace("__WORKER_ID__", workerId) + .replace("__WORKER_SECRET__", workerSecret); } diff --git a/shared/src/ids.ts b/shared/src/ids.ts index d08be2f..50c000a 100644 --- a/shared/src/ids.ts +++ b/shared/src/ids.ts @@ -14,7 +14,7 @@ function randomHex(bytes: number): string { /** # 函数 * 生成带前缀的业务 ID,例如 node_a1b2c3d4e5f60718 - * 参数: prefix - 业务前缀(node/dom/mbx/msg/att/whk/dlv/oauth/key/state/sim) + * 参数: prefix - 业务前缀(acct/dom/mbx/msg/att/dlv/oauth/key/state/sim) */ export function newId(prefix: string): string { return `${prefix}_${randomHex(8)}`; @@ -22,7 +22,7 @@ export function newId(prefix: string): string { export const ids = { user: () => newId("usr"), - node: () => newId("node"), + account: () => newId("acct"), domain: () => newId("dom"), mailbox: () => newId("mbx"), email: () => newId("msg"), diff --git a/shared/src/protocol.ts b/shared/src/protocol.ts index 91f1203..1b34c1a 100644 --- a/shared/src/protocol.ts +++ b/shared/src/protocol.ts @@ -1,13 +1,19 @@ /** - * WmailHub Node <-> Console 通信协议(协议版本 1) + * WmailHub Worker <-> Console 内部协议(docs/04,协议版本 1) * - * 签名规则(docs/04_NODE_PROTOCOL.md): + * Email Worker 调用 Console 的 /internal/email/authorize 与 /internal/email/ingest, + * 每个请求都必须签名: * body_hash = SHA256(raw_body) * canonical = METHOD + "\n" + PATH + "\n" + TIMESTAMP + "\n" + NONCE + "\n" + body_hash - * signature = HMAC-SHA256(credential_secret, canonical) + * signature = HMAC-SHA256(worker_credential_secret, canonical) + * + * 请求头: + * Authorization: WmailHub-Worker + * X-WmailHub-Timestamp / X-WmailHub-Nonce / X-WmailHub-Body-SHA256 / + * X-WmailHub-Signature / X-WmailHub-Protocol * * Console 端拒绝:timestamp 超窗(默认 5 分钟)、重复 nonce、错误 signature、 - * 错误 body hash、已撤销 credential。 + * 错误 body hash、已撤销 credential、已失效账号。 */ import { hmacSha256Hex, sha256Hex, timingSafeEqualHex } from "./crypto.ts"; @@ -18,6 +24,10 @@ export const PROTOCOL_VERSION = "1"; /** # 成员变量: timestamp 允许窗口(秒),默认 5 分钟 **/ export const TIMESTAMP_WINDOW_SECONDS = 300; +/** # 成员变量: Console 内部邮件 API 路径(docs/11 §7/§9) **/ +export const EMAIL_AUTHORIZE_PATH = "/internal/email/authorize"; +export const EMAIL_INGEST_PATH = "/internal/email/ingest"; + /** # 函数 * 计算请求体的 SHA-256 hex(body hash) */ @@ -54,13 +64,14 @@ export async function computeSignature(secret: string, c: SignatureComponents): } /** # 函数 - * Node 端:为发往 Console 的请求生成全部签名请求头 - * 参数: secret - node credential secret;nodeId - 节点 ID;method/url 同 HTTP 语义;body - 原始请求体 + * Worker 端:为发往 Console 内部 API 的请求生成全部签名请求头 + * 参数: secret - worker credential secret;workerId - Email Worker 身份(脚本名); + * method/url 同 HTTP 语义;body - 原始请求体 * 返回值: 可直接放入 fetch headers 的对象 */ -export async function signNodeRequest( +export async function signWorkerRequest( secret: string, - nodeId: string, + workerId: string, method: string, url: string, body: Uint8Array, @@ -77,7 +88,7 @@ export async function signNodeRequest( bodyHash: bh, }); return { - "Authorization": `WmailHub-Node ${nodeId}`, + "Authorization": `WmailHub-Worker ${workerId}`, "X-WmailHub-Timestamp": timestamp, "X-WmailHub-Nonce": nonce, "X-WmailHub-Body-SHA256": bh, @@ -86,9 +97,9 @@ export async function signNodeRequest( }; } -/** # 成员变量: verifyNodeRequest 的输入 **/ -export interface VerifyNodeRequestInput { - /** 请求方声明的 credential secret(Console 从库中按 node_id 取出) **/ +/** # 成员变量: verifyWorkerRequest 的输入 **/ +export interface VerifyWorkerRequestInput { + /** 请求方声明的 credential secret(Console 从库中按 worker_id 取出) **/ secret: string; /** HTTP 方法 **/ method: string; @@ -96,26 +107,28 @@ export interface VerifyNodeRequestInput { url: string; /** 原始请求体 **/ body: Uint8Array; - /** 请求头(大小写不敏感,由调用方传入 Headers 或普通对象) **/ + /** 请求头(大小写不敏感,由调用方传入 Headers) **/ headers: Headers; /** 当前 unix 秒,测试可注入 **/ now?: number; } -/** # 成员变量: verifyNodeRequest 的结果 **/ -export type VerifyNodeResult = - | { ok: true; timestamp: number; nonce: string } +/** # 成员变量: verifyWorkerRequest 的结果 **/ +export type VerifyWorkerResult = + | { ok: true; workerId: string; timestamp: number; nonce: string } | { ok: false; status: 400 | 401; error: string }; /** # 函数 - * Console 端:校验 Node 请求头签名完整性(不含 nonce 防重放与 credential 撤销检查, - * 这两项由调用方结合数据库完成:nonce 用唯一索引入库,credential 状态查询后拒绝)。 + * Console 端:校验 Worker 请求头签名完整性(不含 nonce 防重放、credential/账号状态检查, + * 这三项由调用方结合数据库完成:nonce 用唯一索引入库,credential/账号状态查询后拒绝)。 */ -export async function verifyNodeRequest(input: VerifyNodeRequestInput): Promise { +export async function verifyWorkerRequest(input: VerifyWorkerRequestInput): Promise { const h = input.headers; const auth = h.get("Authorization") ?? ""; - const m = /^WmailHub-Node\s+(.+)$/.exec(auth); - if (!m) return { ok: false, status: 401, error: "missing node authorization" }; + const m = /^WmailHub-Worker\s+(.+)$/.exec(auth); + if (!m) return { ok: false, status: 401, error: "missing worker authorization" }; + const workerId = m[1].trim(); + if (!workerId) return { ok: false, status: 401, error: "empty worker id" }; const timestamp = h.get("X-WmailHub-Timestamp") ?? ""; const nonce = h.get("X-WmailHub-Nonce") ?? ""; @@ -145,17 +158,10 @@ export async function verifyNodeRequest(input: VerifyNodeRequestInput): Promise< nonce, bodyHash: bodySha, }); - if (!timingSafeEqualEqual(expected, signature)) { + if (!timingSafeEqualHex(expected, signature)) { return { ok: false, status: 401, error: "invalid signature" }; } - return { ok: true, timestamp: ts, nonce }; -} - -function timingSafeEqualEqual(a: string, b: string): boolean { - if (a.length !== b.length) return false; - let diff = 0; - for (let i = 0; i < a.length; i++) diff |= a.charCodeAt(i) ^ b.charCodeAt(i); - return diff === 0; + return { ok: true, workerId, timestamp: ts, nonce }; } /** # 函数 diff --git a/tests/api-mcp.test.ts b/tests/api-mcp.test.ts index 6cdd004..c4c6fbb 100644 --- a/tests/api-mcp.test.ts +++ b/tests/api-mcp.test.ts @@ -104,7 +104,7 @@ describe("Console API v1(Bearer wmh_ Key)", () => { raw: new TextEncoder().encode(TEST_MAIL), mailFrom: "a@b.c", rcptTo: "hello@example.test", - nodeId: t.nodeId, + accountId: t.accountId, }); await t.env.DB.prepare("UPDATE emails SET expires_at = '2000-01-01T00:00:00Z' WHERE id = ?") .bind(email.id) @@ -175,7 +175,7 @@ describe("MCP(/api/v1/mcp,JSON-RPC over HTTP)", () => { raw: new TextEncoder().encode(TEST_MAIL), mailFrom: "a@b.c", rcptTo: "hello@example.test", - nodeId: t.nodeId, + accountId: t.accountId, }); const created = await mcpCall(t, mcpKey, { diff --git a/tests/authorize.test.ts b/tests/authorize.test.ts new file mode 100644 index 0000000..8fd7431 --- /dev/null +++ b/tests/authorize.test.ts @@ -0,0 +1,256 @@ +/** authorize 决策矩阵测试(docs/11 §3/§7):accept/reject/temporary_error 语义与拒绝码 */ +import { describe, expect, test } from "bun:test"; +import { handleEmailEvent, default as workerMod, type EmailMessage } from "../worker/src/index.ts"; +import type { WorkerEnv } from "../worker/src/env.ts"; +import { makeTestEnv, signedAuthorizeRequest, TEST_MAIL } from "./helpers.ts"; +import { sha256Hex } from "../shared/src/index.ts"; +import { encryptSecret } from "../console/src/db.ts"; + +/** # 成员变量: 决策结果(decision/code) **/ +interface Decision { + decision: "accept" | "reject" | "temporary_error"; + code?: string; + reason?: string; +} + +/** # 函数 + * 调 authorize 接口并解析决策 + */ +async function authorize( + t: Awaited>, + recipient: string, + sender = "sender@example.net", +): Promise<{ status: number; body: Decision }> { + const resp = await t.app.fetch(await signedAuthorizeRequest(t, recipient, sender), t.env); + return { status: resp.status, body: (await resp.json()) as Decision }; +} + +describe("POST /internal/email/authorize 决策矩阵", () => { + test("存在的 catch-all 域名 -> accept(catch-all 下任意未知地址也接收)", async () => { + const t = await makeTestEnv(); + const r = await authorize(t, "hello@example.test"); + expect(r.status).toBe(200); + expect(r.body.decision).toBe("accept"); + const unknown = await authorize(t, "anyone@example.test"); + expect(unknown.body.decision).toBe("accept"); + }); + + test("不存在邮箱且无有效 catch-all -> reject MAILBOX_NOT_FOUND", async () => { + const t = await makeTestEnv(); + // 删除 catch-all,只留精确地址(禁用的 catch-all 属于 MAILBOX_DISABLED 语义) + await t.env.DB.prepare("DELETE FROM mailboxes WHERE id = ?").bind(t.mailboxId).run(); + const now = new Date().toISOString(); + await t.env.DB.prepare( + "INSERT INTO mailboxes (id, domain_id, address, status, is_catch_all, created_at, updated_at) VALUES ('mbx_exact2', 'dom_test1', 'only@example.test', 'active', 0, ?, ?)", + ) + .bind(now, now) + .run(); + const r = await authorize(t, "unknown@example.test"); + expect(r.status).toBe(200); + expect(r.body.decision).toBe("reject"); + expect(r.body.code).toBe("MAILBOX_NOT_FOUND"); + expect(r.body.reason).toBe("Mailbox does not exist"); + }); + + test("禁用邮箱 -> reject MAILBOX_DISABLED", async () => { + const t = await makeTestEnv(); + const now = new Date().toISOString(); + await t.env.DB.prepare( + "INSERT INTO mailboxes (id, domain_id, address, status, is_catch_all, created_at, updated_at) VALUES ('mbx_disabled', 'dom_test1', 'off@example.test', 'disabled', 0, ?, ?)", + ) + .bind(now, now) + .run(); + const r = await authorize(t, "off@example.test"); + expect(r.body.decision).toBe("reject"); + expect(r.body.code).toBe("MAILBOX_DISABLED"); + }); + + test("禁用 catch-all 邮箱 -> reject MAILBOX_DISABLED(不再回退)", async () => { + const t = await makeTestEnv(); + await t.env.DB.prepare("UPDATE mailboxes SET status = 'disabled' WHERE id = ?").bind(t.mailboxId).run(); + const r = await authorize(t, "anyone@example.test"); + expect(r.body.decision).toBe("reject"); + expect(r.body.code).toBe("MAILBOX_DISABLED"); + }); + + test("pending 域名 -> reject DOMAIN_DISABLED(Email Workers 未启用)", async () => { + const t = await makeTestEnv(); + await t.env.DB.prepare("UPDATE domains SET status = 'pending' WHERE id = ?").bind(t.domainId).run(); + const r = await authorize(t, "hello@example.test"); + expect(r.body.decision).toBe("reject"); + expect(r.body.code).toBe("DOMAIN_DISABLED"); + }); + + test("不存在的域名 -> reject DOMAIN_NOT_FOUND", async () => { + const t = await makeTestEnv(); + const r = await authorize(t, "hello@no-such-domain.test"); + expect(r.body.decision).toBe("reject"); + expect(r.body.code).toBe("DOMAIN_NOT_FOUND"); + }); + + test("Worker 归属校验:credential 所属账号与收件域名必须一致", async () => { + const t = await makeTestEnv(); + const now = new Date().toISOString(); + // 另一个账号持有同名域名 example.test;默认 credential 不应跨账号匹配 + await t.env.DB.prepare( + "INSERT INTO cloudflare_accounts (id, name, cloudflare_account_id, worker_id, status, provision_steps, created_at, updated_at) VALUES ('acct_other', 'other-account', 'cfacct-2', 'wmailhub-email-acct-other', 'active', '{}', ?, ?)", + ) + .bind(now, now) + .run(); + // 默认 credential 收件人域名不存在于本账号(先移除种子域名) -> DOMAIN_NOT_FOUND + await t.env.DB.prepare("DELETE FROM mailboxes WHERE id = ?").bind(t.mailboxId).run(); + await t.env.DB.prepare("DELETE FROM domains WHERE id = ?").bind(t.domainId).run(); + const r = await authorize(t, "hello@example.test"); + expect(r.body.decision).toBe("reject"); + expect(r.body.code).toBe("DOMAIN_NOT_FOUND"); + + // other 账号的 credential + 它自己的域名 -> accept + await t.env.DB.prepare( + "INSERT INTO domains (id, cloudflare_account_id, account_id, name, status, created_at, updated_at) VALUES ('dom_other', 'cfacct-2', 'acct_other', 'example.test', 'active', ?, ?)", + ) + .bind(now, now) + .run(); + await t.env.DB.prepare( + "INSERT INTO mailboxes (id, domain_id, address, status, is_catch_all, created_at, updated_at) VALUES ('mbx_other', 'dom_other', '*@example.test', 'active', 1, ?, ?)", + ) + .bind(now, now) + .run(); + const otherSecret = "whs_other_secret_for_signing"; + await t.env.DB.prepare( + "INSERT INTO worker_credentials (id, cloudflare_account_id, worker_id, secret_hash, secret_enc, secret_last4, status, created_at) VALUES ('wcred_other', 'acct_other', 'wmailhub-email-acct-other', ?, ?, ?, 'active', ?)", + ) + .bind(await sha256Hex(otherSecret), await encryptSecret(t.env, otherSecret), otherSecret.slice(-4), now) + .run(); + const resp = await t.app.fetch( + await signedAuthorizeRequest(t, "hello@example.test", "a@b.c", { + workerId: "wmailhub-email-acct-other", + secret: otherSecret, + }), + t.env, + ); + expect(resp.status).toBe(200); + expect(((await resp.json()) as Decision).decision).toBe("accept"); + }); + + test("畸形收件人 -> reject MAILBOX_NOT_FOUND(不抛 500)", async () => { + const t = await makeTestEnv(); + for (const bad of ["no-at-sign", "@example.test", "a@"]) { + const r = await authorize(t, bad); + expect(r.status).toBe(200); + expect(r.body.decision).toBe("reject"); + } + }); +}); + +/** # 成员变量: fake console 行为配置 **/ +interface FakeConsoleOptions { + /** authorize 阶段响应(缺省 accept) **/ + authorize?: (req: Request) => Response | Promise; + /** ingest 阶段响应(缺省 202 成功) **/ + ingest?: (req: Request) => Response | Promise; + /** 模拟 authorize 阶段网络异常(不可达/超时) **/ + authorizeThrows?: string; +} + +/** # 函数 + * 构造 WorkerEnv:fetchImpl 按路径分发到 fake Console + */ +function makeWorkerEnv(opts: FakeConsoleOptions): WorkerEnv { + const fetchImpl = (async (input: RequestInfo | URL, init?: RequestInit) => { + const req = input instanceof Request ? input : new Request(input, init); + const pathname = new URL(req.url).pathname; + if (pathname === "/internal/email/authorize") { + if (opts.authorizeThrows) throw new Error(opts.authorizeThrows); + return await (opts.authorize ?? (() => Response.json({ decision: "accept" })))(req); + } + if (pathname === "/internal/email/ingest") { + return await (opts.ingest ?? (() => Response.json({ ok: true, id: "msg_x", mailbox_id: "mbx_x" }, { status: 202 })))(req); + } + throw new Error(`unexpected fetch ${pathname}`); + }) as typeof fetch; + return { + CONSOLE_URL: "http://fake-console", + WORKER_ID: "wmailhub-email-test", + WORKER_SECRET: "whs_fake_secret", + fetchImpl, + }; +} + +describe("Email Worker 两阶段行为(临时错误语义)", () => { + const MAIL_BYTES = new TextEncoder().encode(TEST_MAIL); + + function message(): EmailMessage { + return { from: "sender@example.net", to: "test@example.com", raw: MAIL_BYTES }; + } + + test("accept -> ingest 原文提交成功(信封头 + message/rfc822)", async () => { + let ingestBody: Uint8Array | null = null; + const wenv = makeWorkerEnv({ + ingest: async (req) => { + expect(req.headers.get("X-WmailHub-Recipient")).toBe("test@example.com"); + expect(req.headers.get("X-WmailHub-Sender")).toBe("sender@example.net"); + expect(req.headers.get("Content-Type")).toBe("message/rfc822"); + ingestBody = new Uint8Array(await req.arrayBuffer()); + return Response.json({ ok: true, id: "msg_1", mailbox_id: "mbx_1" }, { status: 202 }); + }, + }); + const result = await handleEmailEvent(wenv, message()); + expect(result.action).toBe("accepted"); + expect(result.email_id).toBe("msg_1"); + // 原始 MIME 原样提交(未转 JSON 字符串) + expect(new TextDecoder().decode(ingestBody ?? new Uint8Array())).toContain("plain body"); + }); + + test("reject -> 不调用 ingest,返回拒绝码(setReject 由 email() 执行)", async () => { + const wenv = makeWorkerEnv({ + authorize: () => Response.json({ decision: "reject", code: "MAILBOX_NOT_FOUND", reason: "Mailbox does not exist" }), + }); + const result = await handleEmailEvent(wenv, message()); + expect(result.action).toBe("rejected"); + expect(result.code).toBe("MAILBOX_NOT_FOUND"); + }); + + test("Console 5xx -> temporary_error(不能变成 mailbox not found)", async () => { + const wenv = makeWorkerEnv({ + authorize: () => new Response("boom", { status: 500 }), + }); + const result = await handleEmailEvent(wenv, message()); + expect(result.action).toBe("temporary_error"); + expect(result.reason).toContain("500"); + }); + + test("Console 网络不可达/超时 -> temporary_error", async () => { + const wenv = makeWorkerEnv({ authorizeThrows: "connect timeout" }); + const result = await handleEmailEvent(wenv, message()); + expect(result.action).toBe("temporary_error"); + expect(result.reason).toContain("timeout"); + }); + + test("ingest 失败 -> 不伪造业务拒绝(temporary_error)", async () => { + const wenv = makeWorkerEnv({ + ingest: () => new Response("storage down", { status: 500 }), + }); + const result = await handleEmailEvent(wenv, message()); + expect(result.action).toBe("temporary_error"); + expect(result.reason).toContain("ingest"); + }); + + test("默认 email() handler:reject 调 setReject;temporary 抛异常", async () => { + // reject -> setReject(SMTP 永久拒绝) + const wenv1 = makeWorkerEnv({ + authorize: () => + Response.json({ + decision: "reject", + code: "DOMAIN_DISABLED", + reason: "Receiving for this domain is not enabled", + }), + }); + const reasons: string[] = []; + await workerMod.email({ ...message(), setReject: (r) => reasons.push(r) }, wenv1); + expect(reasons).toEqual(["Receiving for this domain is not enabled"]); + + // temporary_error -> 抛异常交 Cloudflare 重试 + const wenv2 = makeWorkerEnv({ authorizeThrows: "console down" }); + await expect(workerMod.email(message(), wenv2)).rejects.toThrow("console down"); + }); +}); diff --git a/tests/fixtures/e2e-receiver.ts b/tests/fixtures/e2e-receiver.ts index 73b3cd6..8b6c392 100644 --- a/tests/fixtures/e2e-receiver.ts +++ b/tests/fixtures/e2e-receiver.ts @@ -3,6 +3,12 @@ const KEY = "wmh_e2e_key_12345"; Bun.serve({ port: 18790, async fetch(req) { + if (req.method === "GET" && new URL(req.url).pathname === "/__stats") { + return Response.json({ ok: true, note: "see stdout for deliveries" }); + } + if (req.method !== "POST" || new URL(req.url).pathname !== "/hook") { + return Response.json({ error: "not found" }, { status: 404 }); + } const body = await req.text(); const ts = req.headers.get("X-WmailHub-Timestamp") ?? ""; const sig = req.headers.get("X-WmailHub-Signature") ?? ""; @@ -17,7 +23,7 @@ Bun.serve({ event: payload.event, subject: payload.subject, mailbox: payload.mailbox, - custom_header: req.headers.get("X-E2E"), + custom_header: req.headers.get("X-Custom"), }), ); return Response.json({ ok: true }); diff --git a/tests/helpers.ts b/tests/helpers.ts index ff6a006..74d4f65 100644 --- a/tests/helpers.ts +++ b/tests/helpers.ts @@ -12,7 +12,7 @@ import { LocalD1 } from "../console/src/local/d1.ts"; import { LocalR2 } from "../console/src/local/r2.ts"; import type { Env } from "../console/src/env.ts"; import { encryptSecret } from "../console/src/db.ts"; -import { hashPassword, sha256Hex, signNodeRequest } from "../shared/src/index.ts"; +import { hashPassword, sha256Hex, signWorkerRequest } from "../shared/src/index.ts"; import { ids } from "../shared/src/index.ts"; /** # 成员变量: 迁移 SQL(按文件名顺序全部应用,一次读取) **/ @@ -54,17 +54,19 @@ export interface TestEnv { env: Env; app: ReturnType; db: Database; - /** 默认节点 */ - nodeId: string; - /** 默认节点 credential secret(明文,用于签名) */ - nodeSecret: string; + /** 默认账号(cloudflare_accounts) */ + accountId: string; + /** Email Worker 身份(worker_id,即部署脚本名) */ + workerId: string; + /** 默认 worker credential secret(明文,用于签名) */ + workerSecret: string; /** catch-all 域名 example.test 对应的 mailbox id */ mailboxId: string; domainId: string; } /** # 函数 - * 构造隔离测试环境并播种:admin 用户、node、credential、domain、catch-all mailbox + * 构造隔离测试环境并播种:admin 用户、账号、worker credential、domain、catch-all mailbox */ export async function makeTestEnv(): Promise { const db = new Database(":memory:", { strict: true }); @@ -72,6 +74,7 @@ export async function makeTestEnv(): Promise { const env: Env = { DB: new LocalD1(db), R2: new LocalR2(mkdtempSync(path.join(tmpdir(), "wmh-r2-"))), + PUBLIC_BASE_URL: "http://localhost", WMAILHUB_SIM_CF: "1", WMAILHUB_ALLOW_PRIVATE_WEBHOOKS: "1", }; @@ -82,25 +85,26 @@ export async function makeTestEnv(): Promise { .bind(ids.user(), await hashPassword("admin"), now) .run(); - const nodeId = "node_test1"; - const nodeSecret = "wns_test_secret_for_signing"; + const accountId = "acct_test1"; + const workerId = "wmailhub-email-acct-test1"; + const workerSecret = "whs_test_secret_for_signing"; await env.DB.prepare( - `INSERT INTO nodes (id, name, cloudflare_account_id, credential_id, status, ingress_url, ingress_secret_enc, provision_steps, created_at, updated_at) - VALUES (?, 'test-node', 'acct-1', 'cred1', 'online', 'http://127.0.0.1:18788/ingress/email', 'enc', '{}', ?, ?)`, + `INSERT INTO cloudflare_accounts (id, name, cloudflare_account_id, worker_id, status, provision_steps, created_at, updated_at) + VALUES (?, 'test-account', 'cfacct-1', ?, 'active', '{}', ?, ?)`, ) - .bind(nodeId, now, now) + .bind(accountId, workerId, now, now) .run(); await env.DB.prepare( - "INSERT INTO node_credentials (id, node_id, secret_hash, secret_enc, status, created_at) VALUES ('cred1', ?, ?, ?, 'active', ?)", + "INSERT INTO worker_credentials (id, cloudflare_account_id, worker_id, secret_hash, secret_enc, secret_last4, status, created_at) VALUES ('wcred1', ?, ?, ?, ?, ?, 'active', ?)", ) - .bind(nodeId, await sha256Hex(nodeSecret), await encryptSecret(env, nodeSecret), now) + .bind(accountId, workerId, await sha256Hex(workerSecret), await encryptSecret(env, workerSecret), workerSecret.slice(-4), now) .run(); const domainId = "dom_test1"; await env.DB.prepare( - "INSERT INTO domains (id, cloudflare_account_id, node_id, name, status, created_at, updated_at) VALUES (?, 'acct-1', ?, 'example.test', 'active', ?, ?)", + "INSERT INTO domains (id, cloudflare_account_id, account_id, name, status, created_at, updated_at) VALUES (?, 'cfacct-1', ?, 'example.test', 'active', ?, ?)", ) - .bind(domainId, nodeId, now, now) + .bind(domainId, accountId, now, now) .run(); const mailboxId = "mbx_test1"; await env.DB.prepare( @@ -109,7 +113,7 @@ export async function makeTestEnv(): Promise { .bind(mailboxId, domainId, now, now) .run(); - return { env, app, db, nodeId, nodeSecret, mailboxId, domainId }; + return { env, app, db, accountId, workerId, workerSecret, mailboxId, domainId }; } /** # 函数 @@ -130,42 +134,86 @@ export async function loginCookie(test: TestEnv): Promise { return `wmh_session=${m[1]}`; } +/** # 成员变量: 签名请求构造的篡改选项 **/ +export interface SignOverrides { + /** 换用错误 secret(签名必然不匹配) **/ + secret?: string; + /** 声明其它 worker_id **/ + workerId?: string; + /** 篡改 Authorization */ + tamperSignature?: boolean; + /** 发送与签名不一致的 body(body hash mismatch) **/ + tamperBody?: Uint8Array; + /** 覆盖 timestamp(超窗测试) **/ + timestamp?: string; + /** 覆盖 nonce(replay 测试) **/ + reuseNonce?: string; + /** 覆盖协议版本 */ + protocol?: string; +} + /** # 函数 - * 构造已签名的 Node 邮件转发请求 + * 构造已签名的 authorize 请求(docs/11:recipient/sender JSON) */ -export async function signedEmailRequest( +export async function signedAuthorizeRequest( + test: TestEnv, + recipient: string, + sender = "alice@example.org", + overrides: SignOverrides = {}, +): Promise { + const url = "http://localhost/internal/email/authorize"; + const body = new TextEncoder().encode(JSON.stringify({ recipient, sender, message_id: "" })); + const headers = await signWorkerRequest( + overrides.secret ?? test.workerSecret, + overrides.workerId ?? test.workerId, + "POST", + url, + body, + ); + applyOverrides(headers, overrides); + const payload = overrides.tamperBody ?? body; + return new Request(url, { method: "POST", headers, body: toArrayBuffer(payload) }); +} + +/** # 函数 + * 构造已签名的 ingest 请求(raw MIME + 信封头) + */ +export async function signedIngestRequest( test: TestEnv, body: Uint8Array, - overrides: { - secret?: string; - nodeId?: string; - tamperSignature?: boolean; - tamperBody?: Uint8Array; - timestamp?: string; - reuseNonce?: string; - } = {}, + overrides: SignOverrides & { recipient?: string; sender?: string } = {}, ): Promise { - const url = `http://localhost/api/internal/nodes/${overrides.nodeId ?? test.nodeId}/emails`; - // 签名始终基于原始 body;发送 tamperBody 即构成"签名-内容不一致"的篡改请求 - const headers = await signNodeRequest( - overrides.secret ?? test.nodeSecret, - overrides.nodeId ?? test.nodeId, + const url = "http://localhost/internal/email/ingest"; + const headers = await signWorkerRequest( + overrides.secret ?? test.workerSecret, + overrides.workerId ?? test.workerId, "POST", url, body, ); headers["Content-Type"] = "message/rfc822"; - headers["X-WmailHub-Mail-From"] = "alice@example.org"; - headers["X-WmailHub-Rcpt-To"] = "hello@example.test"; - if (overrides.tamperSignature) headers["X-WmailHub-Signature"] = "0".repeat(64); - if (overrides.timestamp) headers["X-WmailHub-Timestamp"] = overrides.timestamp; - if (overrides.reuseNonce) headers["X-WmailHub-Nonce"] = overrides.reuseNonce; + headers["X-WmailHub-Recipient"] = overrides.recipient ?? "hello@example.test"; + headers["X-WmailHub-Sender"] = overrides.sender ?? "alice@example.org"; + applyOverrides(headers, overrides); const payload = overrides.tamperBody ?? body; - const ab = payload.buffer.slice( - payload.byteOffset, - payload.byteOffset + payload.byteLength, - ) as ArrayBuffer; - return new Request(url, { method: "POST", headers, body: ab }); + return new Request(url, { method: "POST", headers, body: toArrayBuffer(payload) }); +} + +/** # 函数 + * 应用测试篡改覆盖(签名/body/timestamp/nonce/协议版本) + */ +function applyOverrides(headers: Record, o: SignOverrides) { + if (o.tamperSignature) headers["X-WmailHub-Signature"] = "0".repeat(64); + if (o.timestamp) headers["X-WmailHub-Timestamp"] = o.timestamp; + if (o.reuseNonce) headers["X-WmailHub-Nonce"] = o.reuseNonce; + if (o.protocol) headers["X-WmailHub-Protocol"] = o.protocol; +} + +/** # 函数 + * Uint8Array -> ArrayBuffer(Request body 需要) + */ +function toArrayBuffer(payload: Uint8Array): ArrayBuffer { + return payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength) as ArrayBuffer; } /** # 函数 diff --git a/tests/oauth.test.ts b/tests/oauth.test.ts index f1d4d85..2de8e38 100644 --- a/tests/oauth.test.ts +++ b/tests/oauth.test.ts @@ -1,13 +1,13 @@ -/** Cloudflare OAuth(本地 sim 模式)测试:自动建账号、域名同步、state CSRF、setup Email Workers、凭据存储 */ +/** Cloudflare OAuth(本地 sim 模式)测试:Connect Account 自动接入、域名同步、state CSRF、setup Email Workers、Worker 凭据 */ import { describe, expect, test } from "bun:test"; import { authedFetch, loginCookie, makeTestEnv } from "./helpers.ts"; -describe("Cloudflare OAuth(sim 模式)", () => { - test("start 免 node_id 自动建账号;approve 同步全部域名;setup 配置 Email Workers", async () => { +describe("Cloudflare OAuth(sim 模式,Connect Cloudflare Account)", () => { + test("start 自动建账号;approve 完成自动接入并同步域名;setup 配置 Email Workers", async () => { const t = await makeTestEnv(); const cookie = await loginCookie(t); - // 添加账号:OAuth start 不带 node_id(服务端自动创建账号) + // Connect Cloudflare Account:start 自动创建账号记录 const start = (await ( await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start") ).json()) as { url: string; state: string }; @@ -20,31 +20,43 @@ describe("Cloudflare OAuth(sim 模式)", () => { headers: { "Content-Type": "application/json" }, body: JSON.stringify({ state: start.state }), }) - ).json()) as { ok: boolean; error?: string; node_id: string }; + ).json()) as { ok: boolean; error?: string; account_id: string }; expect(approve.ok).toBe(true); - // 安装结果:发现 + 域名同步完成 + // 接入结果:发现 + 域名同步 + Email Worker 部署 + Worker 凭据签发 const detail = (await ( - await authedFetch(t, cookie, `http://localhost/api/accounts/${approve.node_id}`) + await authedFetch(t, cookie, `http://localhost/api/accounts/${approve.account_id}`) ).json()) as { - account: { status: string; cloudflare_account_id: string | null; name: string }; + account: { status: string; cloudflare_account_id: string | null; name: string; worker_id: string | null }; steps: Record; domains: { name: string }[]; + credential: { worker_id: string; secret_last4: string; status: string } | null; }; - expect(detail.account.status).toBe("offline"); // 等待 Node 心跳上线 + expect(detail.account.status).toBe("active"); // 接入完成(无心跳概念) 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"]) { + for (const key of ["oauth", "discover", "domains", "worker"]) { expect(detail.steps[key].status).toBe("done"); } - // OAuth 只同步域名,不自动部署 Email Worker(按域名手动触发) + expect(detail.account.worker_id).toContain("wmailhub-email-"); // Worker credential 已签发(库中不存明文 secret) + expect(detail.credential).not.toBeNull(); + expect(detail.credential!.worker_id).toBe(detail.account.worker_id!); + const credRow = await t.env.DB.prepare( + "SELECT secret_hash, secret_enc, secret_last4 FROM worker_credentials WHERE cloudflare_account_id = ?", + ) + .bind(approve.account_id) + .first<{ secret_hash: string; secret_enc: string; secret_last4: string }>(); + expect(credRow?.secret_enc).toBeTruthy(); + expect(credRow!.secret_last4.length).toBe(4); + + // OAuth 只同步域名,Email Routing 按域名手动触发 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 + ).json()) as { items: { id: string; name: string; status: string; account_id: string | null }[] }; + // 种子环境自带一个 cfacct-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"); @@ -53,7 +65,7 @@ describe("Cloudflare OAuth(sim 模式)", () => { 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"); - // setup:为同步出的 example.test 配置 Email Workers(Worker + Routing + catch-all 规则) + // 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" }) @@ -116,50 +128,50 @@ describe("Cloudflare OAuth(sim 模式)", () => { expect(cb.status).toBe(400); }); - test("enrollment:token 换取身份,凭证明文仅返回一次且库中只存摘要/密文", async () => { + test("rotate:Worker credential 轮换,secret 仅本次可见且已配置域名自动重部署", async () => { const t = await makeTestEnv(); const cookie = await loginCookie(t); - // 自动创建账号后,用 rotate 取得 enrollment token + // 自动接入一个账号(approve 走完 worker 步骤:部署 + 签发凭据) 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 start = (await ( + await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/start") + ).json()) as { state: string }; + const approve = (await ( + await authedFetch(t, cookie, "http://localhost/api/oauth/cloudflare/sim/approve", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ state: start.state }), + }) + ).json()) as { ok: boolean; account_id: string }; + const accountId = approve.account_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); + ).json()) as { ok: boolean; worker_id: string; secret: string; redeployed: string[] }; + expect(rotated.ok).toBe(true); + expect(rotated.secret.startsWith("whs_")).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: rotated.enrollment_token }), - }), - t.env, - ) - ).json()) as { node_id: string; credential_secret: string; ingress_secret: string }; - 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'", + // 库中不存明文 secret;旧 credential 已撤销,新 credential 生效 + const creds = await t.env.DB.prepare( + "SELECT status FROM worker_credentials WHERE cloudflare_account_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); - // 无效 token 被拒 - const badEnroll = await t.app.fetch( + .all<{ status: string }>(); + expect(creds.results.filter((r) => r.status === "active").length).toBe(1); + expect(creds.results.filter((r) => r.status === "revoked").length).toBe(1); + }); + + test("旧 Node 协议接口已移除(/api/internal/enroll 不再可用)", async () => { + const t = await makeTestEnv(); + // 该路径已无路由;管理面中间件按未授权拒绝,不会返回 enrollment 结果 + const resp = await t.app.fetch( new Request("http://localhost/api/internal/enroll", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ enrollment_token: "wenr_invalid" }), + body: JSON.stringify({ enrollment_token: "wenr_x" }), }), t.env, ); - expect(badEnroll.status).toBe(401); + expect(resp.status).toBe(401); }); }); diff --git a/tests/pipeline.test.ts b/tests/pipeline.test.ts index 70b1054..2ef61fb 100644 --- a/tests/pipeline.test.ts +++ b/tests/pipeline.test.ts @@ -1,16 +1,16 @@ -/** 邮件管线测试:plain/html/附件/多收件人/malformed/超大邮件/TTL/凭证撤销 */ +/** 邮件管线测试(docs/11 两阶段):ingest 入库/附件/降级/限额/TTL/orphan/凭据撤销 */ import { describe, expect, test } from "bun:test"; import { ingestEmail, resolveMailbox } from "../console/src/services/email_ingest.ts"; import { runCron } from "../console/src/services/cleanup.ts"; import { processDueDeliveries } from "../console/src/services/webhook_delivery.ts"; import { MAX_EMAIL_BYTES } from "../shared/src/index.ts"; -import { makeTestEnv, signedEmailRequest, TEST_MAIL } from "./helpers.ts"; +import { makeTestEnv, signedIngestRequest, TEST_MAIL } from "./helpers.ts"; -describe("邮件接收管线", () => { +describe("邮件接收管线(POST /internal/email/ingest)", () => { test("完整解析:subject/from/to/cc/text/html/附件/邮箱关联/expires_at", async () => { const t = await makeTestEnv(); const body = new TextEncoder().encode(TEST_MAIL); - const resp = await t.app.fetch(await signedEmailRequest(t, body), t.env); + const resp = await t.app.fetch(await signedIngestRequest(t, body), t.env); expect(resp.status).toBe(202); const data = (await resp.json()) as { id: string; mailbox_id: string; expires_at: string }; expect(data.mailbox_id).toBe(t.mailboxId); // catch-all 匹配 @@ -25,6 +25,7 @@ describe("邮件接收管线", () => { expect(JSON.parse(email.cc_addresses as string)).toEqual(["bob@example.net"]); expect(email.text_body).toContain("plain body"); expect(email.html_body).toContain("html body"); + expect(email.account_id).toBe(t.accountId); // expires_at ≈ received_at + 86400s const delta = (new Date(email.expires_at as string).getTime() - new Date(email.received_at as string).getTime()) / 1000; expect(delta).toBe(86_400); @@ -42,7 +43,7 @@ describe("邮件接收管线", () => { test("malformed 邮件不拒收:降级入库,raw 可取回", async () => { const t = await makeTestEnv(); const garbage = new TextEncoder().encode("\x00\x01not-a-mime-at-all\r\n###"); - const resp = await t.app.fetch(await signedEmailRequest(t, garbage), t.env); + const resp = await t.app.fetch(await signedIngestRequest(t, garbage), t.env); expect(resp.status).toBe(202); const count = await t.env.DB.prepare("SELECT COUNT(*) AS n FROM emails").first<{ n: number }>(); expect(count?.n).toBe(1); @@ -79,7 +80,7 @@ describe("邮件接收管线", () => { raw: new TextEncoder().encode(mail), mailFrom: "a@b.c", rcptTo: "hello@example.test", - nodeId: t.nodeId, + accountId: t.accountId, }); const atts = await t.env.DB.prepare("SELECT id FROM email_attachments WHERE email_id = ?") .bind(result.id) @@ -92,7 +93,7 @@ describe("邮件接收管线", () => { test("超大邮件返回 413(资源耗尽防护)", async () => { const t = await makeTestEnv(); const big = new Uint8Array(MAX_EMAIL_BYTES + 1); - const resp = await t.app.fetch(await signedEmailRequest(t, big), t.env); + const resp = await t.app.fetch(await signedIngestRequest(t, big), t.env); expect(resp.status).toBe(413); }); @@ -124,12 +125,40 @@ describe("邮件接收管线", () => { raw: new TextEncoder().encode(TEST_MAIL), mailFrom: "a@b.c", rcptTo: "hello@example.test", - nodeId: t.nodeId, + accountId: t.accountId, }); const delta = (new Date(result.expiresAt).getTime() - new Date(now).getTime()) / 1000; expect(delta).toBeLessThan(400); }); + + test("收件人已不可接收时 ingest 被拒(409,authorize 与 ingest 之间状态变化)", async () => { + const t = await makeTestEnv(); + // 禁用 catch-all 邮箱 -> authorize 逻辑不再接受该收件人 + await t.env.DB.prepare("UPDATE mailboxes SET status = 'disabled' WHERE id = ?") + .bind(t.mailboxId) + .run(); + const body = new TextEncoder().encode(TEST_MAIL); + const resp = await t.app.fetch(await signedIngestRequest(t, body), t.env); + expect(resp.status).toBe(409); + const data = (await resp.json()) as { code: string }; + expect(data.code).toBe("MAILBOX_DISABLED"); + const count = await t.env.DB.prepare("SELECT COUNT(*) AS n FROM emails").first<{ n: number }>(); + expect(count?.n).toBe(0); + }); + + test("域名归属其它账号的 ingest 请求被拒(DOMAIN_NOT_FOUND)", async () => { + const t = await makeTestEnv(); + // 收件人域名不在 credential 所属账号下 + const body = new TextEncoder().encode(TEST_MAIL); + const resp = await t.app.fetch( + await signedIngestRequest(t, body, { recipient: "hello@other-domain.test" }), + t.env, + ); + expect(resp.status).toBe(409); + const data = (await resp.json()) as { code: string }; + expect(data.code).toBe("DOMAIN_NOT_FOUND"); + }); }); describe("TTL 清理与 orphan", () => { @@ -139,7 +168,7 @@ describe("TTL 清理与 orphan", () => { raw: new TextEncoder().encode(TEST_MAIL), mailFrom: "a@b.c", rcptTo: "hello@example.test", - nodeId: t.nodeId, + accountId: t.accountId, }); // 人为把 expires_at 改到过去 await t.env.DB.prepare("UPDATE emails SET expires_at = '2000-01-01T00:00:00Z' WHERE id = ?") @@ -175,16 +204,26 @@ describe("TTL 清理与 orphan", () => { }); }); -describe("credential 撤销", () => { - test("撤销后节点请求被 401,且不因重试恢复", async () => { +describe("Worker credential 撤销", () => { + test("撤销后 Worker 请求被 401,且不因重试恢复", async () => { const t = await makeTestEnv(); - await t.env.DB.prepare("UPDATE node_credentials SET status='revoked', revoked_at=? WHERE id='cred1'") + await t.env.DB.prepare("UPDATE worker_credentials SET status='revoked', revoked_at=? WHERE id='wcred1'") .bind(new Date().toISOString()) .run(); const body = new TextEncoder().encode(TEST_MAIL); - const resp = await t.app.fetch(await signedEmailRequest(t, body), t.env); + const resp = await t.app.fetch(await signedIngestRequest(t, body), t.env); expect(resp.status).toBe(401); const data = (await resp.json()) as { error: string }; expect(data.error).toContain("revoked"); }); + + test("账号 revoked 后 credential 立即失效", async () => { + const t = await makeTestEnv(); + await t.env.DB.prepare("UPDATE cloudflare_accounts SET status = 'revoked' WHERE id = ?") + .bind(t.accountId) + .run(); + const req = await signedIngestRequest(t, new TextEncoder().encode(TEST_MAIL)); + const resp = await t.app.fetch(req, t.env); + expect(resp.status).toBe(401); + }); }); diff --git a/tests/protocol.test.ts b/tests/protocol.test.ts index d7337a1..fc354a3 100644 --- a/tests/protocol.test.ts +++ b/tests/protocol.test.ts @@ -1,13 +1,15 @@ -/** 协议与密钥测试:HMAC 签名、防重放、防篡改、时间窗、wmh_ Key 格式 */ +/** 协议与密钥测试:HMAC 签名、防重放、防篡改、时间窗、wmh_ Key 格式(Worker-Console 协议) */ import { describe, expect, test } from "bun:test"; import { canonicalString, computeSignature, generateWmailKey, isValidWmailKey, - verifyNodeRequest, + sha256Hex, + verifyWorkerRequest, + signWorkerRequest, } from "../shared/src/index.ts"; -import { makeTestEnv, signedEmailRequest, TEST_MAIL } from "./helpers.ts"; +import { makeTestEnv, signedAuthorizeRequest, signedIngestRequest, TEST_MAIL } from "./helpers.ts"; describe("wmh_ Key 规范", () => { test("生成的 Key 以 wmh_ 开头且总长 32", () => { @@ -29,41 +31,35 @@ describe("wmh_ Key 规范", () => { }); }); -describe("HMAC 请求签名", () => { +describe("Worker-Console HMAC 请求签名", () => { test("canonical 串格式符合 docs/04", () => { const c = canonicalString({ method: "POST", - path: "/api/x", + path: "/internal/email/authorize", timestamp: "123", nonce: "n", bodyHash: "h", }); - expect(c).toBe("POST\n/api/x\n123\nn\nh"); + expect(c).toBe("POST\n/internal/email/authorize\n123\nn\nh"); }); - test("正确签名可通过校验", async () => { + test("正确签名可通过校验(Authorization: WmailHub-Worker )", async () => { const body = new TextEncoder().encode("hello"); const headers = new Headers({ - "Authorization": "WmailHub-Node node_1", + "Authorization": "WmailHub-Worker wmailhub-email-x1", "X-WmailHub-Timestamp": String(Math.floor(Date.now() / 1000)), "X-WmailHub-Nonce": "nonce-1", - "X-WmailHub-Body-SHA256": await (async () => { - const { sha256Hex } = await import("../shared/src/index.ts"); - return sha256Hex(body); - })(), + "X-WmailHub-Body-SHA256": await sha256Hex(body), "X-WmailHub-Signature": await computeSignature("s", { method: "POST", path: "/p", timestamp: String(Math.floor(Date.now() / 1000)), nonce: "nonce-1", - bodyHash: await (async () => { - const { sha256Hex } = await import("../shared/src/index.ts"); - return sha256Hex(body); - })(), + bodyHash: await sha256Hex(body), }), "X-WmailHub-Protocol": "1", }); - const result = await verifyNodeRequest({ + const result = await verifyWorkerRequest({ secret: "s", method: "POST", url: "http://x/p", @@ -71,30 +67,36 @@ describe("HMAC 请求签名", () => { headers, }); expect(result.ok).toBe(true); + if (result.ok) expect(result.workerId).toBe("wmailhub-email-x1"); }); - test("错误 secret 签名被拒(伪造 Node 请求)", async () => { + test("错误 secret 签名被拒(伪造 Worker 请求)", async () => { const test = await makeTestEnv(); - const body = new TextEncoder().encode(TEST_MAIL); - const req = await signedEmailRequest(test, body, { secret: "wns_wrong_secret" }); + const req = await signedAuthorizeRequest(test, "hello@example.test", "a@b.c", { secret: "whs_wrong_secret" }); const resp = await test.app.fetch(req, test.env); expect(resp.status).toBe(401); }); - test("body 篡改被拒", async () => { + test("未知 worker_id 被拒(公网匿名调用 /internal/email/*)", async () => { + const test = await makeTestEnv(); + const req = await signedAuthorizeRequest(test, "hello@example.test", "a@b.c", { workerId: "wmailhub-email-unknown" }); + const resp = await test.app.fetch(req, test.env); + expect(resp.status).toBe(401); + }); + + test("body 篡改被拒(authorize 与 ingest 都校验 body hash)", async () => { const test = await makeTestEnv(); const body = new TextEncoder().encode(TEST_MAIL); const tampered = new TextEncoder().encode(TEST_MAIL + "injected"); - const req = await signedEmailRequest(test, body, { tamperBody: tampered }); + const req = await signedIngestRequest(test, body, { tamperBody: tampered }); const resp = await test.app.fetch(req, test.env); expect(resp.status).toBe(401); }); test("过期 timestamp 超窗被拒", async () => { const test = await makeTestEnv(); - const body = new TextEncoder().encode(TEST_MAIL); const old = String(Math.floor(Date.now() / 1000) - 3600); // 1 小时前 - const req = await signedEmailRequest(test, body, { timestamp: old }); + const req = await signedAuthorizeRequest(test, "hello@example.test", "a@b.c", { timestamp: old }); const resp = await test.app.fetch(req, test.env); expect(resp.status).toBe(401); const data = (await resp.json()) as { error: string }; @@ -103,39 +105,24 @@ describe("HMAC 请求签名", () => { test("nonce 重放被拒(replay attack)", async () => { const test = await makeTestEnv(); - const body = new TextEncoder().encode(TEST_MAIL); - const req1 = await signedEmailRequest(test, body); - const resp1 = await test.app.fetch(req1, test.env); - expect(resp1.status).toBe(202); - // 取第一次请求的 nonce 重放(重新签名同一 nonce) - const url = `http://localhost/api/internal/nodes/${test.nodeId}/emails`; - const { signNodeRequest } = await import("../shared/src/index.ts"); - const headers = await signNodeRequest(test.nodeSecret, test.nodeId, "POST", url, body); - headers["Content-Type"] = "message/rfc822"; - const resp2a = await test.app.fetch(new Request(url, { method: "POST", headers, body }), test.env); - expect(resp2a.status).toBe(202); - // 抓第二次请求的完整头,原样重发构成 replay(同 nonce 同签名) - const headers3 = await signNodeRequest(test.nodeSecret, test.nodeId, "POST", url, body); - headers3["Content-Type"] = "message/rfc822"; - const replayReq = () => new Request(url, { method: "POST", headers: headers3, body }); + // 同一组签名头发两次:第一次 nonce 入库通过,第二次构成 replay + const url = "http://localhost/internal/email/authorize"; + const body = new TextEncoder().encode(JSON.stringify({ recipient: "hello@example.test", sender: "a@b.c" })); + const headers = await signWorkerRequest(test.workerSecret, test.workerId, "POST", url, body); + headers["Content-Type"] = "application/json"; + const replayReq = () => new Request(url, { method: "POST", headers, body: body.slice() }); const respFirst = await test.app.fetch(replayReq(), test.env); - expect(respFirst.status).toBe(202); - const resp3 = await test.app.fetch(replayReq(), test.env); - expect(resp3.status).toBe(401); - const data = (await resp3.json()) as { error: string }; + expect(respFirst.status).toBe(200); + const respReplay = await test.app.fetch(replayReq(), test.env); + expect(respReplay.status).toBe(401); + const data = (await respReplay.json()) as { error: string }; expect(data.error).toContain("replay"); }); test("错误协议版本被拒", async () => { const test = await makeTestEnv(); - const body = new TextEncoder().encode(TEST_MAIL); - const req = await signedEmailRequest(test, body); - const headers = new Headers(req.headers); - headers.set("X-WmailHub-Protocol", "9"); - const resp = await test.app.fetch( - new Request(req.url, { method: "POST", headers, body }), - test.env, - ); + const req = await signedAuthorizeRequest(test, "hello@example.test", "a@b.c", { protocol: "9" }); + const resp = await test.app.fetch(req, test.env); expect(resp.status).toBe(400); }); }); diff --git a/tests/settings.test.ts b/tests/settings.test.ts index 33d398b..649c50d 100644 --- a/tests/settings.test.ts +++ b/tests/settings.test.ts @@ -1,4 +1,4 @@ -/** 设置测试:全局 Webhook 读写与 key 语义、默认 Ingress、修改密码 */ +/** 设置测试:全局 Webhook 读写与 key 语义、修改密码 */ import { describe, expect, test } from "bun:test"; import { authedFetch, loginCookie, makeTestEnv } from "./helpers.ts"; @@ -52,29 +52,6 @@ describe("设置(/api/settings)", () => { 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); diff --git a/tests/ui-smoke.ts b/tests/ui-smoke.ts index 273a35d..a4a3051 100644 --- a/tests/ui-smoke.ts +++ b/tests/ui-smoke.ts @@ -80,8 +80,8 @@ 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("WmailHub"))); +const drawerText = (await page.locator(".ant-drawer-title").textContent()) ?? ""; +check("邮件详情抽屉打开(标题=邮件主题)", drawerText.trim().length > 0, drawerText.trim()); await page.screenshot({ path: `${SHOT_DIR}/08-email-detail.png` }); await page.keyboard.press("Escape"); diff --git a/tests/webhook.test.ts b/tests/webhook.test.ts index 94ba007..7213e38 100644 --- a/tests/webhook.test.ts +++ b/tests/webhook.test.ts @@ -84,7 +84,7 @@ describe("Webhook(邮箱 -> 域名 -> 全局)", () => { raw: new TextEncoder().encode(TEST_MAIL), mailFrom: "a@b.c", rcptTo: "hello@example.test", - nodeId: t.nodeId, + accountId: t.accountId, }); const dlv = await t.env.DB.prepare("SELECT id, target_type FROM webhook_deliveries").first<{ id: string; @@ -129,7 +129,7 @@ describe("Webhook(邮箱 -> 域名 -> 全局)", () => { raw: new TextEncoder().encode(TEST_MAIL), mailFrom: "a@b.c", rcptTo: "hello@example.test", - nodeId: t.nodeId, + accountId: t.accountId, }); await processDueDeliveries(t.env, undefined, 20, true); expect(mailboxRecv.requests.length).toBe(1); @@ -145,7 +145,7 @@ describe("Webhook(邮箱 -> 域名 -> 全局)", () => { raw: new TextEncoder().encode(TEST_MAIL), mailFrom: "a@b.c", rcptTo: "hello@example.test", - nodeId: t.nodeId, + accountId: t.accountId, }); await processDueDeliveries(t.env, undefined, 20, true); expect(domainRecv.requests.length).toBe(1); @@ -157,7 +157,7 @@ describe("Webhook(邮箱 -> 域名 -> 全局)", () => { raw: new TextEncoder().encode(TEST_MAIL), mailFrom: "a@b.c", rcptTo: "hello@example.test", - nodeId: t.nodeId, + accountId: t.accountId, }); await processDueDeliveries(t.env, undefined, 20, true); expect(globalRecv.requests.length).toBe(1); @@ -171,7 +171,7 @@ describe("Webhook(邮箱 -> 域名 -> 全局)", () => { raw: new TextEncoder().encode(TEST_MAIL), mailFrom: "a@b.c", rcptTo: "hello@example.test", - nodeId: t.nodeId, + accountId: t.accountId, }); const count = await t.env.DB.prepare("SELECT COUNT(*) AS n FROM webhook_deliveries").first<{ n: number }>(); expect(count?.n).toBe(0); @@ -200,7 +200,7 @@ describe("Webhook(邮箱 -> 域名 -> 全局)", () => { raw: new TextEncoder().encode(TEST_MAIL), mailFrom: "a@b.c", rcptTo: "hello@example.test", - nodeId: t.nodeId, + accountId: t.accountId, }); const { WEBHOOK_MAX_ATTEMPTS } = await import("../shared/src/index.ts"); const row0 = await t.env.DB.prepare( diff --git a/tsconfig.json b/tsconfig.json index a6b6572..bcc4d02 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -15,5 +15,5 @@ "forceConsistentCasingInFileNames": true, "types": ["bun-types"] }, - "include": ["shared/src/**/*", "console/src/**/*", "console/frontend/src/**/*", "node/src/**/*", "worker/src/**/*", "tests/**/*", "scripts/**/*"] + "include": ["shared/src/**/*", "console/src/**/*", "console/frontend/src/**/*", "worker/src/**/*", "tests/**/*", "scripts/**/*"] } diff --git a/worker/src/env.ts b/worker/src/env.ts index bb40d9f..1fc53f8 100644 --- a/worker/src/env.ts +++ b/worker/src/env.ts @@ -1,7 +1,13 @@ -/** Email Worker 环境变量(wrangler vars 注入) */ -export interface ForwarderEnv { - /** Node ingress 完整 URL */ - NODE_INGRESS_URL: string; - /** Worker -> Node 共享密钥 */ - NODE_INGRESS_SECRET: string; +/** Email Worker 环境变量(docs/11;wrangler vars 或本地进程 env 注入) **/ + +/** # 成员变量: Email Worker 运行环境绑定 **/ +export interface WorkerEnv { + /** Console 对外地址(Worker -> Console 的目标) **/ + CONSOLE_URL: string; + /** Worker 身份(与 WorkerCredential.worker_id 一致) **/ + WORKER_ID: string; + /** Worker credential secret(HMAC 签名密钥) **/ + WORKER_SECRET: string; + /** 测试注入:替换 fetch 实现(单测用;缺省用全局 fetch) **/ + fetchImpl?: typeof fetch; } diff --git a/worker/src/index.ts b/worker/src/index.ts index 511f598..c43445d 100644 --- a/worker/src/index.ts +++ b/worker/src/index.ts @@ -1,72 +1,188 @@ /** - * WmailHub Email Worker(部署入口): - * Cloudflare Email Routing -> email() handler -> Node ingress。 - * 只做转发,不存业务数据(docs/01);持有 Node ingress secret(docs/08)。 + * WmailHub Email Worker(docs/11): + * Cloudflare Email Routing -> email() handler -> Console /internal/email/authorize + /ingest + * 只做接收转发链路的"SMTP 决策 + 原文提交",不存业务数据; + * 业务判断全部由 Console 决定: + * accept -> ingest 原始 MIME + * reject -> message.setReject()(SMTP 永久拒绝) + * temporary_error -> 抛异常,交给 Cloudflare 邮件机制处理重试(绝不伪装成邮箱不存在) * - * 生产部署:Console provisioning 自动上传脚本;或 `bunx wrangler deploy`(worker/)。 + * 生产部署:Console 自动上传账号级 Worker 脚本(shared/src/email-worker-template.ts); * 本地联调:`bun worker/src/local.ts` 起 HTTP 服务,POST /__email_test 模拟邮件事件。 */ -import type { ForwarderEnv } from "./env.ts"; +import type { WorkerEnv } from "./env.ts"; +import { + signWorkerRequest, + EMAIL_AUTHORIZE_PATH, + EMAIL_INGEST_PATH, + WORKER_TEST_SECRET_HEADER, + WORKER_ENVELOPE_HEADERS, + PROTOCOL_VERSION, +} from "../../shared/src/index.ts"; -/** # 成员变量: Cloudflare Email Message 最小接口 **/ +/** # 成员变量: Cloudflare Email Message 最小接口(setReject 仅真实事件提供) **/ export interface EmailMessage { from: string; to: string; - raw: ReadableStream; + raw: ReadableStream | ArrayBuffer | Uint8Array; + /** Cloudflare Email Worker 提供的 SMTP 拒绝方法;本地模拟时记录调用 */ + setReject?: (reason: string) => void; +} + +/** # 成员变量: 两阶段处理结果 **/ +export interface HandleEmailResult { + /** accepted: 已 ingest;rejected: 已拒绝(Worker 应 setReject);temporary_error: 临时失败 */ + action: "accepted" | "rejected" | "temporary_error"; + /** reject 原因 / 临时错误说明(不含内部敏感信息) **/ + reason?: string; + /** reject 的业务状态码(DOMAIN_NOT_FOUND 等) **/ + code?: string; + /** ingest 成功时的邮件 id / mailbox id **/ + email_id?: string; + mailbox_id?: string | null; } /** # 函数 - * 转发 raw MIME 到 Node ingress + * 第一阶段:询问 Console 是否接收该邮件(SMTP 决策) + * Console 超时/网络失败/5xx/无法解析 -> temporary_error(不能解释成邮箱不存在) */ -export async function forward(env: ForwarderEnv, raw: ArrayBuffer, from: string, to: string): Promise { - const resp = await fetch(env.NODE_INGRESS_URL, { - method: "POST", - headers: { - "Content-Type": "message/rfc822", - "X-WmailHub-Ingress-Secret": env.NODE_INGRESS_SECRET, - "X-WmailHub-Mail-From": from, - "X-WmailHub-Rcpt-To": to, - }, - body: raw, - }); - return resp; -} - -/** # 函数 - * 处理 Email Worker 邮件事件(与测试入口共用) - */ -export async function handleEmailEvent(env: ForwarderEnv, message: EmailMessage): Promise { - const raw = await new Response(message.raw).arrayBuffer(); - const resp = await forward(env, raw, message.from, message.to); - if (!resp.ok) { - throw new Error(`node ingress responded ${resp.status}`); +export async function authorizeEmail(env: WorkerEnv, recipient: string, sender: string): Promise { + const doFetch = env.fetchImpl ?? fetch; + const body = new TextEncoder().encode(JSON.stringify({ recipient, sender })); + const headers = await signWorkerRequest(env.WORKER_SECRET, env.WORKER_ID, "POST", `${env.CONSOLE_URL}${EMAIL_AUTHORIZE_PATH}`, body); + let resp: Response; + try { + resp = await doFetch(`${env.CONSOLE_URL}${EMAIL_AUTHORIZE_PATH}`, { + method: "POST", + headers: { ...headers, "Content-Type": "application/json" }, + body: body as unknown as BodyInit, + }); + } catch (err) { + return { action: "temporary_error", reason: `console unreachable: ${err instanceof Error ? err.message : String(err)}` }; } + if (!resp.ok) { + // 5xx/4xx 都不能当作邮箱不存在:统一按临时错误处理(docs/11 §15) + return { action: "temporary_error", reason: `console responded ${resp.status}` }; + } + let data: { decision?: string; code?: string; reason?: string }; + try { + data = (await resp.json()) as { decision?: string; code?: string; reason?: string }; + } catch { + return { action: "temporary_error", reason: "console returned non-json" }; + } + if (data.decision === "accept") return { action: "accepted" }; + if (data.decision === "reject") { + return { + action: "rejected", + reason: data.reason || data.code || "Rejected by WmailHub", + code: data.code, + }; + } + return { action: "temporary_error", reason: `console decision: ${data.decision || "unknown"}` }; +} + +/** # 函数 + * 第二阶段:把 raw MIME 提交给 Console 入库(D1 + R2 + Webhook) + * 失败不伪造业务拒绝:记录后按 temporary 处理(由 Cloudflare 重试机制兜底)。 + */ +export async function ingestEmail( + env: WorkerEnv, + raw: ArrayBuffer | Uint8Array, + recipient: string, + sender: string, +): Promise { + const doFetch = env.fetchImpl ?? fetch; + const bytes = raw instanceof Uint8Array ? raw : new Uint8Array(raw); + const headers = await signWorkerRequest(env.WORKER_SECRET, env.WORKER_ID, "POST", `${env.CONSOLE_URL}${EMAIL_INGEST_PATH}`, bytes); + let resp: Response; + try { + resp = await doFetch(`${env.CONSOLE_URL}${EMAIL_INGEST_PATH}`, { + method: "POST", + headers: { + ...headers, + "Content-Type": "message/rfc822", + [WORKER_ENVELOPE_HEADERS.recipient]: recipient, + [WORKER_ENVELOPE_HEADERS.sender]: sender, + }, + body: bytes as unknown as BodyInit, + }); + } catch (err) { + console.error(JSON.stringify({ service: "wmailhub-email-worker", msg: "ingest failed", stage: "fetch", error: String(err) })); + return { action: "temporary_error", reason: `ingest unreachable: ${err instanceof Error ? err.message : String(err)}` }; + } + if (!resp.ok) { + const text = await resp.text(); + // ingest 失败不伪造业务拒绝(docs/11 §15);记录后交 Cloudflare 重试 + console.error(JSON.stringify({ service: "wmailhub-email-worker", msg: "ingest failed", status: resp.status, body: text.slice(0, 300) })); + return { action: "temporary_error", reason: `ingest responded ${resp.status}` }; + } + let data: { id?: string; mailbox_id?: string | null } = {}; + try { + data = (await resp.json()) as { id?: string; mailbox_id?: string | null }; + } catch { + // 非 JSON 视为成功(2xx) + } + return { action: "accepted", email_id: data.id, mailbox_id: data.mailbox_id }; +} + +/** # 函数 + * 处理 Email Worker 邮件事件:完整两阶段(authorize -> ingest) + */ +export async function handleEmailEvent(env: WorkerEnv, message: EmailMessage): Promise { + const decision = await authorizeEmail(env, message.to, message.from); + if (decision.action !== "accepted") return decision; + let raw: ArrayBuffer; + if (message.raw instanceof ArrayBuffer) raw = message.raw; + else if (message.raw instanceof Uint8Array) raw = message.raw.buffer.slice(message.raw.byteOffset, message.raw.byteOffset + message.raw.byteLength) as ArrayBuffer; + else raw = await new Response(message.raw).arrayBuffer(); + const ingestResult = await ingestEmail(env, raw, message.to, message.from); + return ingestResult; } /** # 成员变量: Workers 导出(email + fetch) **/ export default { - async email(message: EmailMessage, env: ForwarderEnv): Promise { - await handleEmailEvent(env, message); + async email(message: EmailMessage, env: WorkerEnv): Promise { + const result = await handleEmailEvent(env, message); + if (result.action === "rejected") { + message.setReject?.(result.reason || "Rejected by WmailHub"); + return; + } + if (result.action === "temporary_error") { + // 不能伪造永久拒绝;抛出异常交给 Cloudflare 邮件机制处理后续重试 + throw new Error(result.reason || "wmailhub console temporary error"); + } }, - async fetch(request: Request, env: ForwarderEnv): Promise { + async fetch(request: Request, env: WorkerEnv): Promise { const url = new URL(request.url); if (url.pathname === "/__health") { - return Response.json({ ok: true, service: "wmailhub-email-worker" }); + return Response.json({ ok: true, service: "wmailhub-email-worker", protocol: PROTOCOL_VERSION }); } if (request.method === "POST" && url.pathname === "/__email_test") { - if (request.headers.get("X-WmailHub-Ingress-Secret") !== env.NODE_INGRESS_SECRET) { + // 本地/联调测试入口:与 email handler 走同一条两阶段链路 + if (request.headers.get(WORKER_TEST_SECRET_HEADER) !== env.WORKER_SECRET) { return Response.json({ error: "forbidden" }, { status: 403 }); } const from = request.headers.get("X-WmailHub-Test-From") || "sender@example.org"; const to = request.headers.get("X-WmailHub-Test-To") || "hello@example.com"; const raw = await request.arrayBuffer(); + const rejectReasons: string[] = []; + const message: EmailMessage = { + from, + to, + raw, + setReject: (reason) => rejectReasons.push(reason), + }; try { - const resp = await forward(env, raw, from, to); - return Response.json({ ok: resp.ok, status: resp.status }, { status: resp.ok ? 200 : 502 }); + const result = await handleEmailEvent(env, message); + if (rejectReasons.length > 0) { + // 模拟 SMTP 永久拒绝(生产由 message.setReject 完成) + return Response.json({ action: "rejected", reason: rejectReasons[0] }, { status: 200 }); + } + return Response.json(result, { status: result.action === "accepted" ? 200 : 502 }); } catch (err) { - return Response.json({ error: String(err) }, { status: 502 }); + return Response.json({ action: "temporary_error", error: String(err) }, { status: 502 }); } } return Response.json({ error: "not found" }, { status: 404 }); diff --git a/worker/src/local.ts b/worker/src/local.ts index 58460eb..d6896bb 100644 --- a/worker/src/local.ts +++ b/worker/src/local.ts @@ -1,21 +1,22 @@ /** - * Email Worker 本地联调服务:把部署入口的 fetch handler 跑在 Bun 上。 + * Email Worker 本地联调服务:把部署入口的 fetch handler 跑在 Bun 上(docs/11)。 * bun worker/src/local.ts * 测试:POST http://127.0.0.1:18789/__email_test - * headers: X-WmailHub-Ingress-Secret / X-WmailHub-Test-From / X-WmailHub-Test-To + * headers: X-WmailHub-Test-Secret / X-WmailHub-Test-From / X-WmailHub-Test-To * body: raw MIME */ import worker from "./index.ts"; -import type { ForwarderEnv } from "./env.ts"; +import type { WorkerEnv } from "./env.ts"; -const env: ForwarderEnv = { - NODE_INGRESS_URL: process.env.NODE_INGRESS_URL ?? "http://127.0.0.1:18788/ingress/email", - NODE_INGRESS_SECRET: process.env.NODE_INGRESS_SECRET ?? "", +const env: WorkerEnv = { + CONSOLE_URL: process.env.CONSOLE_URL ?? "http://127.0.0.1:18787", + WORKER_ID: process.env.WORKER_ID ?? "", + WORKER_SECRET: process.env.WORKER_SECRET ?? "", }; -if (!env.NODE_INGRESS_SECRET) { - console.error("missing NODE_INGRESS_SECRET env"); +if (!env.WORKER_ID || !env.WORKER_SECRET) { + console.error("missing WORKER_ID / WORKER_SECRET env"); process.exit(1); } @@ -32,7 +33,8 @@ console.log( lvl: "info", msg: "email worker started", port: PORT, - node_ingress: env.NODE_INGRESS_URL, + worker_id: env.WORKER_ID, + console_url: env.CONSOLE_URL, }), ); diff --git a/worker/wrangler.jsonc b/worker/wrangler.jsonc index 41de1b5..3fe4bd3 100644 --- a/worker/wrangler.jsonc +++ b/worker/wrangler.jsonc @@ -1,15 +1,13 @@ -// WmailHub Email Worker —— Cloudflare 部署配置(生产)。 -// 通常不由本文件部署:Console 在自动安装 Node 时通过 Cloudflare API 上传脚本并注入 -// Node ingress 地址/密钥(docs/02)。此配置用于手工部署调试: -// bunx wrangler deploy (在 worker/ 目录) -// 部署后还需在 Zone 的 Email Routing -> Email Workers 中把 catch-all 指到该 Worker -// (Console 的 provisioning 会自动完成同样动作)。 +// WmailHub Email Worker —— Cloudflare 部署配置(手工调试用)。 +// 通常不由本文件部署:Console 在「Connect Cloudflare Account」/ 域名配置 Email Workers 时, +// 通过 Cloudflare API 上传账号级 Worker 脚本并注入 Console 地址/Worker 凭据(docs/11)。 { "name": "wmailhub-email-worker", "main": "src/index.ts", "compatibility_date": "2026-09-01", "vars": { - "NODE_INGRESS_URL": "http://127.0.0.1:8788/ingress/email", - "NODE_INGRESS_SECRET": "replace-with-node-ingress-secret" + "CONSOLE_URL": "https://wmailhub-console..workers.dev", + "WORKER_ID": "replace-with-worker-id", + "WORKER_SECRET": "replace-with-worker-secret" } }