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:
bigcode
2026-09-30 13:10:26 +08:00
parent 421cfb2f04
commit fec96e49cc
56 changed files with 2584 additions and 1416 deletions
+53 -39
View File
@@ -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 与邮件正文。
+2 -2
View File
@@ -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
View File
@@ -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: "请输入原密码" }]}>
+45 -61
View File
@@ -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>
+1 -1
View File
@@ -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}>
+3 -3
View File
@@ -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>
) : (
+1 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}
+3 -2
View File
@@ -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
+5 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
}
/** # 函数
+84 -59
View File
@@ -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 });
});
+5 -21
View File
@@ -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>;
+20 -13
View File
@@ -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 };
}
+7 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 不允许处理其它账号的域名。
+29 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 均有清理路径。
+34 -10
View File
@@ -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 限制调整。
+38 -43
View File
@@ -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`。
+676
View File
@@ -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 邮件架构中移除
+51
View File
@@ -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);
-247
View File
@@ -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);
});
-4
View File
@@ -1,4 +0,0 @@
/** Node 版本:与项目根 package.json 一致(全局版本规范,禁止手写常量) */
import pkg from "../../package.json";
export const APP_VERSION: string = pkg.version;
+2 -2
View File
@@ -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
View File
@@ -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("==========================================");
+7 -4
View File
@@ -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;
+146 -33
View File
@@ -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
View File
@@ -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
View File
@@ -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 };
}
/** # 函数
+2 -2
View File
@@ -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, {
+256
View File
@@ -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");
});
});
+7 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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");
+6 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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,
}),
);
+6 -8
View File
@@ -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"
}
}