feat: v2.0.0 邮件接收架构改造 —— 移除 Node,Email Worker 直连 Console(docs/11)
- 两阶段接收:POST /internal/email/authorize(accept/reject/temporary_error)+ POST /internal/email/ingest(raw MIME 直传) - Worker-Console 协议:Authorization: WmailHub-Worker + timestamp/nonce/body-hash/HMAC,nonce 防重放,凭据撤销即时生效 - 数据模型:nodes/node_credentials -> cloudflare_accounts/worker_credentials(迁移 0003),emails/domains 归属 account_id - 接入流程 Connect Cloudflare Account:OAuth 自动同步域名 + 部署账号级 Email Worker + 签发 Worker 凭据;rotate 自动重部署 - 动态邮箱:创建/删除/禁用下一封邮件立即生效;拒绝码 DOMAIN_*/MAILBOX_*;超时/5xx 不伪装成邮箱不存在 - 删除 node/ 目录、旧 /api/internal/nodes 协议、ingress/enrollment 概念;dev.ts 简化为 console + email worker 双进程 - 前端:账号页 Email Worker/凭据管理,设置窗口移除默认 Ingress,仪表盘/文案同步;版本 2.0.0 - 测试 62 项全过(新增 authorize 决策矩阵 + Worker 两阶段临时错误语义);本机虚拟 E2E 12 场景全通 - docs 00-10 同步改写;README 更新
This commit is contained in:
@@ -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 <key>` 携带,并用于 `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 <node_id>`、`X-WmailHub-Timestamp`(±5 分钟)、`X-WmailHub-Nonce`(防重放)、`X-WmailHub-Body-SHA256`、`X-WmailHub-Signature`、`X-WmailHub-Protocol: 1`。
|
||||
请求头:`Authorization: WmailHub-Worker <worker_id>`、`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 与回调 `<PUBLIC_BASE_URL>/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 与回调 `<PUBLIC_BASE_URL>/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 与邮件正文。
|
||||
|
||||
@@ -28,7 +28,7 @@ const MENU_ITEMS = [
|
||||
/** # 成员变量: 路由 -> 页面标题/副标题 **/
|
||||
const PAGE_META: Record<string, { title: string; subtitle: string }> = {
|
||||
"/": { 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() {
|
||||
<div className="subtitle">{meta.subtitle}</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", alignItems: "center", gap: 14 }}>
|
||||
<Tooltip title="设置(全局 Webhook / Ingress / 密码 / API & MCP Keys)">
|
||||
<Tooltip title="设置(全局 Webhook / 密码 / API & MCP Keys)">
|
||||
<Button
|
||||
type="text"
|
||||
icon={<SettingOutlined />}
|
||||
|
||||
+20
-20
@@ -88,8 +88,8 @@ export const health = () => api<{ ok: boolean; name: string; version: string }>(
|
||||
/** Dashboard 统计 */
|
||||
export const stats = () =>
|
||||
api<{
|
||||
accounts_online: number;
|
||||
accounts_total: number;
|
||||
accounts_active: number;
|
||||
domains: number;
|
||||
domains_active: number;
|
||||
mailboxes: number;
|
||||
@@ -98,59 +98,61 @@ export const stats = () =>
|
||||
global_webhook: number;
|
||||
deliveries_pending: number;
|
||||
oauth_connections: number;
|
||||
worker_credentials: number;
|
||||
recent_emails: { id: string; subject: string; from_address: string; received_at: string; size: number }[];
|
||||
}>("/api/admin/stats");
|
||||
|
||||
/** 手动触发 Cron */
|
||||
export const runCron = () => api<{ ok: boolean; stats: Record<string, number | string[]> }>("/api/admin/cron/run", "POST");
|
||||
|
||||
/* ---- 账号(原节点) ---- */
|
||||
/* ---- 账号(Cloudflare 账户) ---- */
|
||||
|
||||
export interface AccountRow {
|
||||
id: string;
|
||||
name: string;
|
||||
cloudflare_account_id: string | null;
|
||||
worker_id: string | null;
|
||||
status: string;
|
||||
version: string | null;
|
||||
last_seen_at: string | null;
|
||||
ingress_url: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export const listAccounts = () => api<{ items: AccountRow[]; total: number }>("/api/accounts");
|
||||
|
||||
export interface AccountDetail {
|
||||
account: AccountRow & { enrollment_expires_at: string | null };
|
||||
account: AccountRow;
|
||||
steps: Record<string, { status: string; error?: string }>;
|
||||
oauth: { id: string; account_id: string | null; created_at: string } | null;
|
||||
domains: { id: string; name: string; status: string; worker_id: string | null }[];
|
||||
credential: { id: string; worker_id: string; secret_last4: string; status: string; created_at: string } | null;
|
||||
}
|
||||
|
||||
export const getAccount = (id: string) => api<AccountDetail>(`/api/accounts/${id}`);
|
||||
|
||||
export const updateAccount = (id: string, body: { name?: string; ingress_url?: string }) =>
|
||||
export const updateAccount = (id: string, body: { name?: string }) =>
|
||||
api(`/api/accounts/${id}`, "PATCH", body);
|
||||
|
||||
export const deleteAccount = (id: string) => api(`/api/accounts/${id}`, "DELETE");
|
||||
|
||||
/** 轮换 Worker credential:secret 仅本次返回可见;已配置域名自动重新部署 */
|
||||
export const rotateAccount = (id: string) =>
|
||||
api<{ ok: boolean; enrollment_token: string }>(`/api/accounts/${id}/rotate`, "POST");
|
||||
api<{ ok: boolean; worker_id: string; credential_id: string; secret: string; redeployed: string[] }>(
|
||||
`/api/accounts/${id}/rotate`,
|
||||
"POST",
|
||||
);
|
||||
|
||||
export const getAccountIngressSecret = (id: string) =>
|
||||
api<{ ingress_secret: string }>(`/api/accounts/${id}/ingress-secret`);
|
||||
/** 本地/运维:查看账号当前 Worker credential(secret 用于部署/联调) */
|
||||
export const getAccountWorkerSecret = (id: string) =>
|
||||
api<{ worker_id: string; secret: string }>(`/api/accounts/${id}/worker-secret`);
|
||||
|
||||
/* ---- OAuth ---- */
|
||||
|
||||
export const oauthMode = () => api<{ mode: "cloudflare" | "sim" }>("/api/oauth/mode");
|
||||
|
||||
/** 开始授权:nodeId 缺省 = 自动创建账号 */
|
||||
export const oauthStart = (nodeId?: string) =>
|
||||
api<{ url: string; state: string }>(
|
||||
`/api/oauth/cloudflare/start${nodeId ? `?node_id=${encodeURIComponent(nodeId)}` : ""}`,
|
||||
);
|
||||
/** 开始授权(自动创建账号记录;授权后自动接入) */
|
||||
export const oauthStart = () => api<{ url: string; state: string }>("/api/oauth/cloudflare/start");
|
||||
|
||||
export const oauthSimApprove = (state: string) =>
|
||||
api<{ ok: boolean; error?: string; node_id: string }>("/api/oauth/cloudflare/sim/approve", "POST", { state });
|
||||
api<{ ok: boolean; error?: string; account_id: string }>("/api/oauth/cloudflare/sim/approve", "POST", { state });
|
||||
|
||||
/* ---- 域名 ---- */
|
||||
|
||||
@@ -161,8 +163,8 @@ export interface DomainRow {
|
||||
setup_error: string | null;
|
||||
worker_id: string | null;
|
||||
zone_id: string | null;
|
||||
node_id: string | null;
|
||||
node_name: string | null;
|
||||
account_id: string | null;
|
||||
account_name: string | null;
|
||||
webhook_url: string | null;
|
||||
webhook_headers: string | null;
|
||||
webhook_has_key: number;
|
||||
@@ -257,7 +259,6 @@ export const deleteEmail = (id: string) => api(`/api/emails/${id}`, "DELETE");
|
||||
|
||||
export interface SettingsInfo {
|
||||
username: string;
|
||||
default_ingress_url: string;
|
||||
webhook: { url: string; headers: Record<string, string>; has_key: boolean; key_last4: string };
|
||||
}
|
||||
|
||||
@@ -265,7 +266,6 @@ export const getSettings = () => api<SettingsInfo>("/api/settings");
|
||||
|
||||
/** 保存设置;webhook.key:undefined 保留原值、空串清除、非空设置新 key */
|
||||
export const putSettings = (body: {
|
||||
default_ingress_url?: string;
|
||||
webhook?: { url: string; headers?: Record<string, string>; key?: string };
|
||||
}) => api("/api/settings", "PUT", body);
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
/**
|
||||
* 设置模态窗口(v1.1.0):
|
||||
* - 基本设置:全局 Webhook(url/自定义头/key)+ 默认 Node Ingress 地址 + 修改密码
|
||||
* 设置模态窗口(v2.0.0):
|
||||
* - 基本设置:全局 Webhook(url/自定义头/key)+ 修改密码
|
||||
* - API Keys / MCP Keys:wmh_ 凭据的生成、撤销、删除(原 /keys 页面收拢于此)
|
||||
*/
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
@@ -71,12 +71,11 @@ interface WebhookFormValues {
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 基本设置:全局 Webhook + 默认 Node Ingress + 修改密码
|
||||
* 基本设置:全局 Webhook + 修改密码
|
||||
*/
|
||||
function GeneralTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void }) {
|
||||
const { message } = App.useApp();
|
||||
const [webhookForm] = Form.useForm<WebhookFormValues>();
|
||||
const [ingressForm] = Form.useForm<{ ingress: string }>();
|
||||
const [pwdForm] = Form.useForm<{
|
||||
old_password: string;
|
||||
new_password: string;
|
||||
@@ -91,7 +90,7 @@ function GeneralTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void
|
||||
});
|
||||
}, [info, webhookForm]);
|
||||
|
||||
/** 保存全局 Webhook 与默认 Ingress */
|
||||
/** 保存全局 Webhook */
|
||||
const saveWebhook = async () => {
|
||||
const values = await webhookForm.validateFields();
|
||||
let headers: Record<string, string> = {};
|
||||
@@ -106,7 +105,6 @@ function GeneralTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void
|
||||
setSaving(true);
|
||||
try {
|
||||
await putSettings({
|
||||
default_ingress_url: info.default_ingress_url,
|
||||
webhook: { url: values.url.trim(), headers, key: values.key },
|
||||
});
|
||||
message.success("全局 Webhook 已保存");
|
||||
@@ -119,18 +117,6 @@ function GeneralTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void
|
||||
}
|
||||
};
|
||||
|
||||
/** 保存默认 Node Ingress 地址 */
|
||||
const saveIngress = async () => {
|
||||
const values = await ingressForm.validateFields();
|
||||
try {
|
||||
await putSettings({ default_ingress_url: values.ingress.trim() });
|
||||
message.success("默认 Node Ingress 地址已保存");
|
||||
onSaved();
|
||||
} catch (err) {
|
||||
message.error(err instanceof Error ? err.message : "保存失败");
|
||||
}
|
||||
};
|
||||
|
||||
/** 修改密码 */
|
||||
const savePassword = async () => {
|
||||
const values = await pwdForm.validateFields();
|
||||
@@ -179,20 +165,6 @@ function GeneralTab({ info, onSaved }: { info: SettingsInfo; onSaved: () => void
|
||||
</Button>
|
||||
</Form>
|
||||
|
||||
<Form form={ingressForm} layout="vertical" initialValues={{ ingress: info.default_ingress_url }}>
|
||||
<Typography.Title level={5}>默认 Node Ingress 地址</Typography.Title>
|
||||
<Typography.Text type="secondary" style={{ display: "block", marginBottom: 8 }}>
|
||||
「添加账号」创建的账号默认使用的 Node Ingress 地址:即 Email Worker 收到邮件后转发到的 Node 接收入口
|
||||
(Node 再签名转发给 Console)。生产环境填 Node 的公网 HTTPS 地址。
|
||||
</Typography.Text>
|
||||
<Space.Compact style={{ width: "100%" }}>
|
||||
<Form.Item name="ingress" noStyle rules={[{ required: true, message: "请输入地址" }]}>
|
||||
<Input placeholder="http://127.0.0.1:18788/ingress/email" />
|
||||
</Form.Item>
|
||||
<Button onClick={() => void saveIngress()}>保存</Button>
|
||||
</Space.Compact>
|
||||
</Form>
|
||||
|
||||
<Form form={pwdForm} layout="vertical" onFinish={() => void savePassword()}>
|
||||
<Typography.Title level={5}>修改密码</Typography.Title>
|
||||
<Form.Item name="old_password" label="原密码" rules={[{ required: true, message: "请输入原密码" }]}>
|
||||
|
||||
@@ -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<string, string> = {
|
||||
pending_oauth: "orange",
|
||||
provisioning: "processing",
|
||||
online: "success",
|
||||
offline: "default",
|
||||
active: "success",
|
||||
error: "error",
|
||||
};
|
||||
|
||||
/** # 成员变量: 安装步骤中文标签(与后端 PROVISION_STEPS 对应) **/
|
||||
/** # 成员变量: 接入步骤中文标签(与后端 PROVISION_STEPS 对应) **/
|
||||
const STEP_LABEL: Record<string, string> = {
|
||||
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)}
|
||||
/>
|
||||
<span className="muted">
|
||||
点击「添加账号」完成 Cloudflare OAuth,自动同步账号下全部域名;在域名列表逐域名开启 Email Workers。
|
||||
点击「添加账号」完成 Cloudflare OAuth;Console 自动同步域名、部署 Email Worker
|
||||
并签发 Worker 凭据(Email Worker 直连 Console)。
|
||||
</span>
|
||||
</div>
|
||||
<Space>
|
||||
@@ -149,29 +147,23 @@ export function AccountsPage() {
|
||||
render: (v: string) => <Tag color={STATUS_COLOR[v] ?? "default"}>{v}</Tag>,
|
||||
},
|
||||
{ title: "Cloudflare 账户", dataIndex: "cloudflare_account_id", ellipsis: true, render: (v: string | null) => v ?? "-" },
|
||||
{ title: "Ingress", dataIndex: "ingress_url", ellipsis: true, render: (v: string) => <span className="mono">{v}</span> },
|
||||
{ title: "版本", dataIndex: "version", width: 90, render: (v: string | null) => v ?? "-" },
|
||||
{
|
||||
title: "最近心跳",
|
||||
dataIndex: "last_seen_at",
|
||||
title: "Email Worker",
|
||||
dataIndex: "worker_id",
|
||||
ellipsis: true,
|
||||
render: (v: string | null) => (v ? <span className="mono">{v}</span> : <span className="muted">未部署</span>),
|
||||
},
|
||||
{
|
||||
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) => (
|
||||
<Space>
|
||||
<Button
|
||||
size="small"
|
||||
disabled={row.status !== "pending_oauth"}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void startOAuth(row.id);
|
||||
}}
|
||||
>
|
||||
授权
|
||||
</Button>
|
||||
<Button
|
||||
size="small"
|
||||
danger
|
||||
@@ -179,7 +171,7 @@ export function AccountsPage() {
|
||||
e.stopPropagation();
|
||||
modal.confirm({
|
||||
title: `删除账号 ${row.name}?`,
|
||||
content: "将撤销账号凭据并标记为 revoked;Node 内部协议将立即失效。",
|
||||
content: "将撤销该账号的 Worker 凭据并标记为 revoked;Email Worker 将无法访问 Console。",
|
||||
onOk: async () => {
|
||||
await deleteAccount(row.id);
|
||||
message.success("已删除");
|
||||
@@ -211,7 +203,7 @@ export function AccountsPage() {
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 账号详情抽屉:安装步骤时间线 + 域名 + ingress/凭据管理
|
||||
* 账号详情抽屉:接入步骤时间线 + 域名 + Email Worker 凭据管理
|
||||
*/
|
||||
function AccountDetailDrawer({
|
||||
detail,
|
||||
@@ -225,14 +217,10 @@ function AccountDetailDrawer({
|
||||
onReload: () => void;
|
||||
}) {
|
||||
const { message, modal } = App.useApp();
|
||||
const [ingress, setIngress] = useState("");
|
||||
const [name, setName] = useState("");
|
||||
|
||||
useEffect(() => {
|
||||
if (detail) {
|
||||
setIngress(detail.account.ingress_url);
|
||||
setName(detail.account.name);
|
||||
}
|
||||
if (detail) setName(detail.account.name);
|
||||
}, [detail]);
|
||||
|
||||
if (!detail) return null;
|
||||
@@ -242,7 +230,7 @@ function AccountDetailDrawer({
|
||||
<Drawer title={`账号:${detail.account.name}`} size="large" open={open} onClose={onClose}>
|
||||
<Space orientation="vertical" size="large" style={{ width: "100%" }}>
|
||||
<div>
|
||||
<Typography.Title level={5}>安装进度</Typography.Title>
|
||||
<Typography.Title level={5}>接入进度</Typography.Title>
|
||||
<Timeline
|
||||
items={Object.entries(STEP_LABEL).map(([key, label]) => {
|
||||
const st = steps[key]?.status ?? "pending";
|
||||
@@ -278,58 +266,54 @@ function AccountDetailDrawer({
|
||||
)}
|
||||
</Card>
|
||||
|
||||
<Card size="small" title="Ingress 与凭据">
|
||||
<Card size="small" title="Email Worker 与凭据">
|
||||
<Space orientation="vertical" style={{ width: "100%" }} size="middle">
|
||||
<Space.Compact style={{ width: "100%" }}>
|
||||
<Input
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
style={{ width: 180 }}
|
||||
placeholder="账号名称"
|
||||
/>
|
||||
<Input
|
||||
value={ingress}
|
||||
onChange={(e) => setIngress(e.target.value)}
|
||||
placeholder="Node Ingress 地址"
|
||||
/>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
await updateAccount(detail.account.id, { name, ingress_url: ingress });
|
||||
message.success("已保存(新邮件将从新 Ingress 转发)");
|
||||
await updateAccount(detail.account.id, { name });
|
||||
message.success("已保存");
|
||||
onReload();
|
||||
}}
|
||||
>
|
||||
保存
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
<Typography.Text type="secondary" className="mono">
|
||||
Worker ID:{detail.account.worker_id ?? "-"}
|
||||
{detail.credential ? ` · 凭据 ****${detail.credential.secret_last4}(有效)` : ""}
|
||||
</Typography.Text>
|
||||
<Typography.Text type="secondary" style={{ display: "block" }}>
|
||||
Email Worker 以该凭据直连 Console(/internal/email/authorize 与 /ingest);轮换后已配置的域名会自动重新部署。
|
||||
</Typography.Text>
|
||||
<Space wrap>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const res = await rotateAccount(detail.account.id);
|
||||
modal.info({
|
||||
title: "新的 Enrollment Token(仅显示一次)",
|
||||
content: <Typography.Paragraph copyable className="mono">{res.enrollment_token}</Typography.Paragraph>,
|
||||
title: "新的 Worker Secret(仅显示一次)",
|
||||
content: (
|
||||
<div>
|
||||
<Typography.Paragraph copyable className="mono" style={{ marginBottom: 4 }}>
|
||||
{res.secret}
|
||||
</Typography.Paragraph>
|
||||
<Typography.Text type="secondary">
|
||||
已自动重新部署域名:{res.redeployed.length ? res.redeployed.join("、") : "无(尚无已配置域名)"}
|
||||
</Typography.Text>
|
||||
</div>
|
||||
),
|
||||
});
|
||||
onReload();
|
||||
}}
|
||||
>
|
||||
轮换凭据(生成新 Enrollment Token)
|
||||
</Button>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
const res = await getAccountIngressSecret(detail.account.id);
|
||||
modal.info({
|
||||
title: "Ingress Secret(Email Worker -> Node 共享密钥)",
|
||||
content: <Typography.Paragraph copyable className="mono">{res.ingress_secret}</Typography.Paragraph>,
|
||||
});
|
||||
}}
|
||||
>
|
||||
查看 Ingress Secret
|
||||
轮换 Worker 凭据
|
||||
</Button>
|
||||
</Space>
|
||||
<Typography.Text type="secondary" className="mono">
|
||||
enrollment 有效期至 {detail.account.enrollment_expires_at ?? "-"}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
</Card>
|
||||
</Space>
|
||||
|
||||
@@ -49,7 +49,7 @@ export function DashboardPage() {
|
||||
<Row gutter={[16, 16]}>
|
||||
<Col xs={12} md={8} lg={4}>
|
||||
<Card loading={loading}>
|
||||
<Statistic title="账号在线" value={data?.accounts_online ?? 0} suffix={`/ ${data?.accounts_total ?? 0}`} />
|
||||
<Statistic title="Cloudflare 账号" value={data?.accounts_active ?? 0} suffix={`/ ${data?.accounts_total ?? 0}`} />
|
||||
</Card>
|
||||
</Col>
|
||||
<Col xs={12} md={8} lg={4}>
|
||||
|
||||
@@ -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) => <span className="mono">{v}</span> },
|
||||
{ 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) => <StatusTag row={row} /> },
|
||||
{
|
||||
title: "Email Worker",
|
||||
@@ -174,7 +174,7 @@ export function DomainsPage() {
|
||||
<span>
|
||||
<span className="mono">{editing.name}</span>
|
||||
<Typography.Text type="secondary" style={{ marginLeft: 8, fontWeight: 400 }}>
|
||||
{editing.node_name ?? "-"}
|
||||
{editing.account_name ?? "-"}
|
||||
</Typography.Text>
|
||||
</span>
|
||||
) : (
|
||||
|
||||
@@ -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 });
|
||||
|
||||
+3
-3
@@ -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());
|
||||
|
||||
+1
-1
@@ -49,7 +49,7 @@ export async function getMasterSecret(env: Env): Promise<string> {
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 获取 AES 加密密钥(OAuth token、node credential secret、ingress secret 加密存储)
|
||||
* 获取 AES 加密密钥(OAuth token、worker credential secret、webhook key 加密存储)
|
||||
*/
|
||||
export async function getAesKey(env: Env): Promise<CryptoKey> {
|
||||
const master = await getMasterSecret(env);
|
||||
|
||||
+5
-3
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
+143
-83
@@ -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<string, string> = {
|
||||
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<EmailDecision> {
|
||||
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;
|
||||
}
|
||||
|
||||
+26
-39
@@ -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 };
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
@@ -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<string, string>;
|
||||
|
||||
@@ -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<CFAccount[]>;
|
||||
/** 发现账户下的域名 Zone */
|
||||
listZones(accessToken: string, accountId: string): Promise<CFZone[]>;
|
||||
/** 创建或更新 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 };
|
||||
}
|
||||
|
||||
@@ -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<IngestR
|
||||
|
||||
// 入库
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO emails (id, node_id, mailbox_id, message_id, from_address, to_addresses, cc_addresses,
|
||||
`INSERT INTO emails (id, account_id, mailbox_id, message_id, from_address, to_addresses, cc_addresses,
|
||||
subject, text_body, html_body, raw_storage_key, size, received_at, expires_at, created_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
)
|
||||
.bind(
|
||||
emailId,
|
||||
input.nodeId,
|
||||
input.accountId,
|
||||
mailboxId,
|
||||
parsed?.messageId ?? null,
|
||||
fromAddress,
|
||||
@@ -215,7 +215,7 @@ export class IngestError extends Error {
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 收件地址 -> mailbox 解析:精确匹配优先,其次域名 catch-all
|
||||
* 收件地址 -> mailbox 解析:精确匹配优先,其次域名 catch-all(仅 active)
|
||||
*/
|
||||
export async function resolveMailbox(env: Env, addresses: string[]): Promise<string | null> {
|
||||
for (const addr of addresses) {
|
||||
|
||||
+175
-151
@@ -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<string, { status: StepStatus; error?: str
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 账号的 Email Worker 脚本名(幂等:同一账号固定同名)
|
||||
* 账号的 Email Worker 脚本名(幂等:同一账号固定同名;同时作为 Worker -> 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<string> {
|
||||
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<string> {
|
||||
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<void> {
|
||||
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;
|
||||
}
|
||||
|
||||
+35
-40
@@ -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 邮箱缓存与同步(性能需要时再评估,须接受传播延迟)
|
||||
|
||||
+41
-21
@@ -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。
|
||||
一个账号绑定一个 Cloudflare Account 与一个 Email Worker;Worker credential 不允许处理其它账号的域名。
|
||||
|
||||
@@ -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 必须验证。
|
||||
## 安全
|
||||
|
||||
- Cloudflare credential(OAuth access token)AES-GCM 加密存储,不进入前端和日志。
|
||||
- OAuth state 一次性(用后即删)、5 分钟过期、绑定账号(CSRF 防护)。
|
||||
- Account ownership 以 token 实际可访问的 accounts 为准。
|
||||
- 安装过程幂等,重复执行不产生重复资源。
|
||||
- Worker credential 与 Cloudflare credential 分离;轮换 credential 后已配置域名自动重新部署。
|
||||
|
||||
+19
-37
@@ -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)。
|
||||
|
||||
+25
-32
@@ -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 <node_id>
|
||||
Authorization: WmailHub-Worker <worker_id>
|
||||
X-WmailHub-Timestamp: <unix_seconds>
|
||||
X-WmailHub-Nonce: <random>
|
||||
X-WmailHub-Body-SHA256: <hex>
|
||||
@@ -32,18 +14,29 @@ X-WmailHub-Signature: <hex>
|
||||
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 应视为凭据失效,不得无限重试。协议必须版本化。
|
||||
|
||||
+40
-16
@@ -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)。
|
||||
|
||||
+29
-11
@@ -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 和临时日志都应有生命周期,不做无限期保存。
|
||||
|
||||
+22
-11
@@ -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 <key>`,自定义 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。
|
||||
|
||||
+25
-25
@@ -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 均有清理路径。
|
||||
|
||||
@@ -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-<account_id>),脚本由 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 限制调整。
|
||||
|
||||
@@ -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`。
|
||||
|
||||
@@ -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": "<abc@example.net>"
|
||||
}
|
||||
|
||||
响应只能使用明确 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 <worker_id>
|
||||
X-WmailHub-Timestamp: <unix_seconds>
|
||||
X-WmailHub-Nonce: <random>
|
||||
X-WmailHub-Body-SHA256: <sha256>
|
||||
X-WmailHub-Signature: <hmac>
|
||||
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 邮件架构中移除
|
||||
@@ -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);
|
||||
@@ -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<string, unknown> = {}) {
|
||||
console.log(JSON.stringify({ t: new Date().toISOString(), lvl, msg, ...extra }));
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 加载配置:环境变量优先,其次 config.json(enrollment 产物)
|
||||
*/
|
||||
async function loadConfig(): Promise<NodeConfig> {
|
||||
let file: Partial<NodeConfig> = {};
|
||||
try {
|
||||
file = (await Bun.file(CONFIG_FILE).json()) as Partial<NodeConfig>;
|
||||
} catch {
|
||||
// 无 config.json,纯环境变量模式
|
||||
}
|
||||
const env = process.env;
|
||||
const consoleUrl = env.WMAILHUB_CONSOLE_URL ?? file.consoleUrl;
|
||||
const ingressSecret = env.WMAILHUB_INGRESS_SECRET ?? file.ingressSecret;
|
||||
let nodeId = env.WMAILHUB_NODE_ID ?? file.nodeId;
|
||||
let nodeSecret = env.WMAILHUB_NODE_SECRET ?? file.nodeSecret;
|
||||
|
||||
if (!consoleUrl || !ingressSecret) {
|
||||
console.error("missing WMAILHUB_CONSOLE_URL / WMAILHUB_INGRESS_SECRET");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
// bootstrap enrollment:有 token 而无身份时自动换取并落盘(0600)
|
||||
const enrollmentToken = env.WMAILHUB_ENROLLMENT_TOKEN ?? "";
|
||||
if ((!nodeId || !nodeSecret) && enrollmentToken) {
|
||||
log("info", "enrolling with console");
|
||||
const resp = await fetch(new URL("/api/internal/enroll", consoleUrl), {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ enrollment_token: enrollmentToken }),
|
||||
});
|
||||
if (!resp.ok) {
|
||||
log("error", "enroll failed", { status: resp.status });
|
||||
process.exit(1);
|
||||
}
|
||||
const data = (await resp.json()) as {
|
||||
node_id: string;
|
||||
credential_id: string;
|
||||
credential_secret: string;
|
||||
ingress_secret: string;
|
||||
};
|
||||
nodeId = data.node_id;
|
||||
nodeSecret = data.credential_secret;
|
||||
await Bun.write(CONFIG_FILE, JSON.stringify({ consoleUrl, nodeId, nodeSecret, ingressSecret: ingressSecret }), {
|
||||
mode: 0o600,
|
||||
});
|
||||
log("info", "enrolled, secure config written", { node_id: nodeId, credential_id: data.credential_id });
|
||||
}
|
||||
|
||||
if (!nodeId || !nodeSecret) {
|
||||
console.error("missing WMAILHUB_NODE_ID / WMAILHUB_NODE_SECRET (or WMAILHUB_ENROLLMENT_TOKEN)");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
return {
|
||||
consoleUrl: consoleUrl.replace(/\/$/, ""),
|
||||
nodeId,
|
||||
nodeSecret,
|
||||
ingressSecret,
|
||||
port: Number(env.WMAILHUB_PORT ?? 18788),
|
||||
};
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 转发邮件到 Console:构造 HMAC 签名请求;网络错误/5xx/429 有限重试,401/403 立即放弃
|
||||
*/
|
||||
async function forwardToConsole(cfg: NodeConfig, raw: Uint8Array, mailFrom: string, rcptTo: string): Promise<Response> {
|
||||
const url = `${cfg.consoleUrl}/api/internal/nodes/${cfg.nodeId}/emails`;
|
||||
const maxAttempts = 3;
|
||||
let lastError = "";
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
const headers = await signNodeRequest(cfg.nodeSecret, cfg.nodeId, "POST", url, raw);
|
||||
headers["Content-Type"] = "message/rfc822";
|
||||
headers["X-WmailHub-Mail-From"] = mailFrom;
|
||||
headers["X-WmailHub-Rcpt-To"] = rcptTo;
|
||||
try {
|
||||
const resp = await fetch(url, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: raw.buffer.slice(raw.byteOffset, raw.byteOffset + raw.byteLength) as ArrayBuffer,
|
||||
});
|
||||
if (resp.ok) return resp;
|
||||
if (resp.status === 401 || resp.status === 403) {
|
||||
// 凭据问题:重试无意义(docs/04:不得无限重试)
|
||||
return resp;
|
||||
}
|
||||
lastError = `console responded ${resp.status}`;
|
||||
} catch (err) {
|
||||
lastError = err instanceof Error ? err.message : String(err);
|
||||
}
|
||||
if (attempt < maxAttempts) {
|
||||
const delayMs = 500 * 2 ** (attempt - 1);
|
||||
log("warn", "forward retry scheduled", { attempt, delayMs, error: lastError });
|
||||
await new Promise((r) => setTimeout(r, delayMs));
|
||||
}
|
||||
}
|
||||
return new Response(JSON.stringify({ error: lastError }), { status: 503 });
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 发送一次心跳;401/403 只告警不退出
|
||||
*/
|
||||
async function heartbeat(cfg: NodeConfig): Promise<void> {
|
||||
const url = `${cfg.consoleUrl}/api/internal/nodes/${cfg.nodeId}/heartbeat`;
|
||||
const body = new TextEncoder().encode(JSON.stringify({ version: APP_VERSION }));
|
||||
const headers = await signNodeRequest(cfg.nodeSecret, cfg.nodeId, "POST", url, body);
|
||||
headers["Content-Type"] = "application/json";
|
||||
try {
|
||||
const resp = await fetch(url, { method: "POST", headers, body });
|
||||
if (!resp.ok) {
|
||||
log("warn", "heartbeat rejected", { status: resp.status });
|
||||
} else {
|
||||
log("info", "heartbeat ok");
|
||||
}
|
||||
} catch (err) {
|
||||
log("warn", "heartbeat failed", { error: err instanceof Error ? err.message : String(err) });
|
||||
}
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 主入口:加载配置 -> 起服务 -> 定时心跳 -> 优雅退出
|
||||
*/
|
||||
async function main() {
|
||||
const cfg = await loadConfig();
|
||||
let shuttingDown = false;
|
||||
let inflight = 0;
|
||||
|
||||
const server: Server<unknown> = Bun.serve({
|
||||
port: cfg.port,
|
||||
hostname: "0.0.0.0",
|
||||
idleTimeout: 30,
|
||||
async fetch(request) {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/__health") {
|
||||
return Response.json({ ok: true, node_id: cfg.nodeId, version: APP_VERSION });
|
||||
}
|
||||
// 邮件 ingress:仅接受 Email Worker 的签名 secret 请求
|
||||
if (request.method === "POST" && url.pathname === "/ingress/email") {
|
||||
if (request.headers.get(INGRESS_SECRET_HEADER) !== cfg.ingressSecret) {
|
||||
log("warn", "ingress rejected: bad secret", { ip: request.headers.get("x-forwarded-for") ?? "" });
|
||||
return Response.json({ error: "forbidden" }, { status: 403 });
|
||||
}
|
||||
const raw = new Uint8Array(await request.arrayBuffer());
|
||||
if (raw.byteLength === 0) {
|
||||
return Response.json({ error: "empty body" }, { status: 400 });
|
||||
}
|
||||
const mailFrom = request.headers.get("X-WmailHub-Mail-From") ?? "";
|
||||
const rcptTo = request.headers.get("X-WmailHub-Rcpt-To") ?? "";
|
||||
inflight++;
|
||||
try {
|
||||
const resp = await forwardToConsole(cfg, raw, mailFrom, rcptTo);
|
||||
const body = (await resp.json().catch(() => ({}))) as Record<string, unknown>;
|
||||
log(resp.ok ? "info" : "error", resp.ok ? "email forwarded" : "forward failed", {
|
||||
bytes: raw.byteLength,
|
||||
status: resp.status,
|
||||
email_id: body.id,
|
||||
});
|
||||
return Response.json(body, { status: resp.status });
|
||||
} finally {
|
||||
inflight--;
|
||||
}
|
||||
}
|
||||
return Response.json({ error: "not found" }, { status: 404 });
|
||||
},
|
||||
});
|
||||
|
||||
log("info", "node started", {
|
||||
node_id: cfg.nodeId,
|
||||
port: cfg.port,
|
||||
console: cfg.consoleUrl,
|
||||
version: APP_VERSION,
|
||||
});
|
||||
|
||||
// 心跳(启动即一次)
|
||||
void heartbeat(cfg);
|
||||
const heartbeatTimer = setInterval(() => void heartbeat(cfg), NODE_HEARTBEAT_INTERVAL_MS);
|
||||
|
||||
/** 优雅退出:停止接新请求,等在途转发完成 */
|
||||
async function shutdown(signal: string) {
|
||||
if (shuttingDown) return;
|
||||
shuttingDown = true;
|
||||
log("info", "node shutting down", { signal, inflight });
|
||||
clearInterval(heartbeatTimer);
|
||||
server.stop(false); // 停止接受新连接,保持既有
|
||||
const deadline = Date.now() + 10_000;
|
||||
while (inflight > 0 && Date.now() < deadline) {
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
}
|
||||
server.stop(true);
|
||||
process.exit(0);
|
||||
}
|
||||
process.on("SIGINT", () => void shutdown("SIGINT"));
|
||||
process.on("SIGTERM", () => void shutdown("SIGTERM"));
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
log("error", "node crashed", { error: err instanceof Error ? err.stack : String(err) });
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -1,4 +0,0 @@
|
||||
/** Node 版本:与项目根 package.json 一致(全局版本规范,禁止手写常量) */
|
||||
import pkg from "../../package.json";
|
||||
|
||||
export const APP_VERSION: string = pkg.version;
|
||||
+2
-2
@@ -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": {
|
||||
|
||||
+27
-49
@@ -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<string, string> = {
|
||||
WMAILHUB_CONSOLE_URL: BASE,
|
||||
WMAILHUB_INGRESS_SECRET: ingressSecret,
|
||||
WMAILHUB_PORT: String(NODE_PORT),
|
||||
};
|
||||
if (enrollmentToken) nodeEnv.WMAILHUB_ENROLLMENT_TOKEN = enrollmentToken;
|
||||
spawn("node", ["node/src/main.ts"], ROOT, nodeEnv);
|
||||
await waitReady(`http://127.0.0.1:${NODE_PORT}/__health`, "node");
|
||||
console.log("[dev] node ready");
|
||||
|
||||
// 6. Email Worker
|
||||
// 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: <secret>' \\");
|
||||
console.log(" -H 'X-WmailHub-Test-Secret: <worker secret>' \\");
|
||||
console.log(" -H 'X-WmailHub-Test-To: hello@example.test' --data-binary @mail.eml");
|
||||
console.log("==========================================");
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
+2
-2
@@ -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"),
|
||||
|
||||
+36
-30
@@ -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 <worker_id>
|
||||
* 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<VerifyNodeResult> {
|
||||
export async function verifyWorkerRequest(input: VerifyWorkerRequestInput): Promise<VerifyWorkerResult> {
|
||||
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 };
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
|
||||
@@ -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, {
|
||||
|
||||
@@ -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<ReturnType<typeof makeTestEnv>>,
|
||||
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<Response>;
|
||||
/** ingest 阶段响应(缺省 202 成功) **/
|
||||
ingest?: (req: Request) => Response | Promise<Response>;
|
||||
/** 模拟 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");
|
||||
});
|
||||
});
|
||||
Vendored
+7
-1
@@ -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 });
|
||||
|
||||
+89
-41
@@ -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<typeof createApp>;
|
||||
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<TestEnv> {
|
||||
const db = new Database(":memory:", { strict: true });
|
||||
@@ -72,6 +74,7 @@ export async function makeTestEnv(): Promise<TestEnv> {
|
||||
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<TestEnv> {
|
||||
.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<TestEnv> {
|
||||
.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<string> {
|
||||
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<Request> {
|
||||
const url = "http://localhost/internal/email/authorize";
|
||||
const body = new TextEncoder().encode(JSON.stringify({ recipient, sender, message_id: "<t-1@example.org>" }));
|
||||
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<Request> {
|
||||
const url = `http://localhost/api/internal/nodes/${overrides.nodeId ?? test.nodeId}/emails`;
|
||||
// 签名始终基于原始 body;发送 tamperBody 即构成"签名-内容不一致"的篡改请求
|
||||
const headers = await signNodeRequest(
|
||||
overrides.secret ?? test.nodeSecret,
|
||||
overrides.nodeId ?? test.nodeId,
|
||||
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<string, string>, 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;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
|
||||
+57
-45
@@ -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<string, { status: string }>;
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
+52
-13
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
+38
-51
@@ -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 <worker_id>)", 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);
|
||||
});
|
||||
});
|
||||
|
||||
+1
-24
@@ -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);
|
||||
|
||||
+2
-2
@@ -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");
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
+1
-1
@@ -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/**/*"]
|
||||
}
|
||||
|
||||
+12
-6
@@ -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;
|
||||
}
|
||||
|
||||
+154
-38
@@ -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<Response> {
|
||||
const resp = await fetch(env.NODE_INGRESS_URL, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "message/rfc822",
|
||||
"X-WmailHub-Ingress-Secret": env.NODE_INGRESS_SECRET,
|
||||
"X-WmailHub-Mail-From": from,
|
||||
"X-WmailHub-Rcpt-To": to,
|
||||
},
|
||||
body: raw,
|
||||
});
|
||||
return resp;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 处理 Email Worker 邮件事件(与测试入口共用)
|
||||
*/
|
||||
export async function handleEmailEvent(env: ForwarderEnv, message: EmailMessage): Promise<void> {
|
||||
const raw = await new Response(message.raw).arrayBuffer();
|
||||
const resp = await forward(env, raw, message.from, message.to);
|
||||
if (!resp.ok) {
|
||||
throw new Error(`node ingress responded ${resp.status}`);
|
||||
export async function authorizeEmail(env: WorkerEnv, recipient: string, sender: string): Promise<HandleEmailResult> {
|
||||
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<HandleEmailResult> {
|
||||
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<HandleEmailResult> {
|
||||
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<void> {
|
||||
await handleEmailEvent(env, message);
|
||||
async email(message: EmailMessage, env: WorkerEnv): Promise<void> {
|
||||
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<Response> {
|
||||
async fetch(request: Request, env: WorkerEnv): Promise<Response> {
|
||||
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 });
|
||||
|
||||
+11
-9
@@ -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,
|
||||
}),
|
||||
);
|
||||
|
||||
|
||||
@@ -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.<your-subdomain>.workers.dev",
|
||||
"WORKER_ID": "replace-with-worker-id",
|
||||
"WORKER_SECRET": "replace-with-worker-secret"
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user