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:
+89
-41
@@ -12,7 +12,7 @@ import { LocalD1 } from "../console/src/local/d1.ts";
|
||||
import { LocalR2 } from "../console/src/local/r2.ts";
|
||||
import type { Env } from "../console/src/env.ts";
|
||||
import { encryptSecret } from "../console/src/db.ts";
|
||||
import { hashPassword, sha256Hex, signNodeRequest } from "../shared/src/index.ts";
|
||||
import { hashPassword, sha256Hex, signWorkerRequest } from "../shared/src/index.ts";
|
||||
import { ids } from "../shared/src/index.ts";
|
||||
|
||||
/** # 成员变量: 迁移 SQL(按文件名顺序全部应用,一次读取) **/
|
||||
@@ -54,17 +54,19 @@ export interface TestEnv {
|
||||
env: Env;
|
||||
app: ReturnType<typeof createApp>;
|
||||
db: Database;
|
||||
/** 默认节点 */
|
||||
nodeId: string;
|
||||
/** 默认节点 credential secret(明文,用于签名) */
|
||||
nodeSecret: string;
|
||||
/** 默认账号(cloudflare_accounts) */
|
||||
accountId: string;
|
||||
/** Email Worker 身份(worker_id,即部署脚本名) */
|
||||
workerId: string;
|
||||
/** 默认 worker credential secret(明文,用于签名) */
|
||||
workerSecret: string;
|
||||
/** catch-all 域名 example.test 对应的 mailbox id */
|
||||
mailboxId: string;
|
||||
domainId: string;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 构造隔离测试环境并播种:admin 用户、node、credential、domain、catch-all mailbox
|
||||
* 构造隔离测试环境并播种:admin 用户、账号、worker credential、domain、catch-all mailbox
|
||||
*/
|
||||
export async function makeTestEnv(): Promise<TestEnv> {
|
||||
const db = new Database(":memory:", { strict: true });
|
||||
@@ -72,6 +74,7 @@ export async function makeTestEnv(): Promise<TestEnv> {
|
||||
const env: Env = {
|
||||
DB: new LocalD1(db),
|
||||
R2: new LocalR2(mkdtempSync(path.join(tmpdir(), "wmh-r2-"))),
|
||||
PUBLIC_BASE_URL: "http://localhost",
|
||||
WMAILHUB_SIM_CF: "1",
|
||||
WMAILHUB_ALLOW_PRIVATE_WEBHOOKS: "1",
|
||||
};
|
||||
@@ -82,25 +85,26 @@ export async function makeTestEnv(): Promise<TestEnv> {
|
||||
.bind(ids.user(), await hashPassword("admin"), now)
|
||||
.run();
|
||||
|
||||
const nodeId = "node_test1";
|
||||
const nodeSecret = "wns_test_secret_for_signing";
|
||||
const accountId = "acct_test1";
|
||||
const workerId = "wmailhub-email-acct-test1";
|
||||
const workerSecret = "whs_test_secret_for_signing";
|
||||
await env.DB.prepare(
|
||||
`INSERT INTO nodes (id, name, cloudflare_account_id, credential_id, status, ingress_url, ingress_secret_enc, provision_steps, created_at, updated_at)
|
||||
VALUES (?, 'test-node', 'acct-1', 'cred1', 'online', 'http://127.0.0.1:18788/ingress/email', 'enc', '{}', ?, ?)`,
|
||||
`INSERT INTO cloudflare_accounts (id, name, cloudflare_account_id, worker_id, status, provision_steps, created_at, updated_at)
|
||||
VALUES (?, 'test-account', 'cfacct-1', ?, 'active', '{}', ?, ?)`,
|
||||
)
|
||||
.bind(nodeId, now, now)
|
||||
.bind(accountId, workerId, now, now)
|
||||
.run();
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO node_credentials (id, node_id, secret_hash, secret_enc, status, created_at) VALUES ('cred1', ?, ?, ?, 'active', ?)",
|
||||
"INSERT INTO worker_credentials (id, cloudflare_account_id, worker_id, secret_hash, secret_enc, secret_last4, status, created_at) VALUES ('wcred1', ?, ?, ?, ?, ?, 'active', ?)",
|
||||
)
|
||||
.bind(nodeId, await sha256Hex(nodeSecret), await encryptSecret(env, nodeSecret), now)
|
||||
.bind(accountId, workerId, await sha256Hex(workerSecret), await encryptSecret(env, workerSecret), workerSecret.slice(-4), now)
|
||||
.run();
|
||||
|
||||
const domainId = "dom_test1";
|
||||
await env.DB.prepare(
|
||||
"INSERT INTO domains (id, cloudflare_account_id, node_id, name, status, created_at, updated_at) VALUES (?, 'acct-1', ?, 'example.test', 'active', ?, ?)",
|
||||
"INSERT INTO domains (id, cloudflare_account_id, account_id, name, status, created_at, updated_at) VALUES (?, 'cfacct-1', ?, 'example.test', 'active', ?, ?)",
|
||||
)
|
||||
.bind(domainId, nodeId, now, now)
|
||||
.bind(domainId, accountId, now, now)
|
||||
.run();
|
||||
const mailboxId = "mbx_test1";
|
||||
await env.DB.prepare(
|
||||
@@ -109,7 +113,7 @@ export async function makeTestEnv(): Promise<TestEnv> {
|
||||
.bind(mailboxId, domainId, now, now)
|
||||
.run();
|
||||
|
||||
return { env, app, db, nodeId, nodeSecret, mailboxId, domainId };
|
||||
return { env, app, db, accountId, workerId, workerSecret, mailboxId, domainId };
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
@@ -130,42 +134,86 @@ export async function loginCookie(test: TestEnv): Promise<string> {
|
||||
return `wmh_session=${m[1]}`;
|
||||
}
|
||||
|
||||
/** # 成员变量: 签名请求构造的篡改选项 **/
|
||||
export interface SignOverrides {
|
||||
/** 换用错误 secret(签名必然不匹配) **/
|
||||
secret?: string;
|
||||
/** 声明其它 worker_id **/
|
||||
workerId?: string;
|
||||
/** 篡改 Authorization */
|
||||
tamperSignature?: boolean;
|
||||
/** 发送与签名不一致的 body(body hash mismatch) **/
|
||||
tamperBody?: Uint8Array;
|
||||
/** 覆盖 timestamp(超窗测试) **/
|
||||
timestamp?: string;
|
||||
/** 覆盖 nonce(replay 测试) **/
|
||||
reuseNonce?: string;
|
||||
/** 覆盖协议版本 */
|
||||
protocol?: string;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 构造已签名的 Node 邮件转发请求
|
||||
* 构造已签名的 authorize 请求(docs/11:recipient/sender JSON)
|
||||
*/
|
||||
export async function signedEmailRequest(
|
||||
export async function signedAuthorizeRequest(
|
||||
test: TestEnv,
|
||||
recipient: string,
|
||||
sender = "alice@example.org",
|
||||
overrides: SignOverrides = {},
|
||||
): Promise<Request> {
|
||||
const url = "http://localhost/internal/email/authorize";
|
||||
const body = new TextEncoder().encode(JSON.stringify({ recipient, sender, message_id: "<t-1@example.org>" }));
|
||||
const headers = await signWorkerRequest(
|
||||
overrides.secret ?? test.workerSecret,
|
||||
overrides.workerId ?? test.workerId,
|
||||
"POST",
|
||||
url,
|
||||
body,
|
||||
);
|
||||
applyOverrides(headers, overrides);
|
||||
const payload = overrides.tamperBody ?? body;
|
||||
return new Request(url, { method: "POST", headers, body: toArrayBuffer(payload) });
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 构造已签名的 ingest 请求(raw MIME + 信封头)
|
||||
*/
|
||||
export async function signedIngestRequest(
|
||||
test: TestEnv,
|
||||
body: Uint8Array,
|
||||
overrides: {
|
||||
secret?: string;
|
||||
nodeId?: string;
|
||||
tamperSignature?: boolean;
|
||||
tamperBody?: Uint8Array;
|
||||
timestamp?: string;
|
||||
reuseNonce?: string;
|
||||
} = {},
|
||||
overrides: SignOverrides & { recipient?: string; sender?: string } = {},
|
||||
): Promise<Request> {
|
||||
const url = `http://localhost/api/internal/nodes/${overrides.nodeId ?? test.nodeId}/emails`;
|
||||
// 签名始终基于原始 body;发送 tamperBody 即构成"签名-内容不一致"的篡改请求
|
||||
const headers = await signNodeRequest(
|
||||
overrides.secret ?? test.nodeSecret,
|
||||
overrides.nodeId ?? test.nodeId,
|
||||
const url = "http://localhost/internal/email/ingest";
|
||||
const headers = await signWorkerRequest(
|
||||
overrides.secret ?? test.workerSecret,
|
||||
overrides.workerId ?? test.workerId,
|
||||
"POST",
|
||||
url,
|
||||
body,
|
||||
);
|
||||
headers["Content-Type"] = "message/rfc822";
|
||||
headers["X-WmailHub-Mail-From"] = "alice@example.org";
|
||||
headers["X-WmailHub-Rcpt-To"] = "hello@example.test";
|
||||
if (overrides.tamperSignature) headers["X-WmailHub-Signature"] = "0".repeat(64);
|
||||
if (overrides.timestamp) headers["X-WmailHub-Timestamp"] = overrides.timestamp;
|
||||
if (overrides.reuseNonce) headers["X-WmailHub-Nonce"] = overrides.reuseNonce;
|
||||
headers["X-WmailHub-Recipient"] = overrides.recipient ?? "hello@example.test";
|
||||
headers["X-WmailHub-Sender"] = overrides.sender ?? "alice@example.org";
|
||||
applyOverrides(headers, overrides);
|
||||
const payload = overrides.tamperBody ?? body;
|
||||
const ab = payload.buffer.slice(
|
||||
payload.byteOffset,
|
||||
payload.byteOffset + payload.byteLength,
|
||||
) as ArrayBuffer;
|
||||
return new Request(url, { method: "POST", headers, body: ab });
|
||||
return new Request(url, { method: "POST", headers, body: toArrayBuffer(payload) });
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* 应用测试篡改覆盖(签名/body/timestamp/nonce/协议版本)
|
||||
*/
|
||||
function applyOverrides(headers: Record<string, string>, o: SignOverrides) {
|
||||
if (o.tamperSignature) headers["X-WmailHub-Signature"] = "0".repeat(64);
|
||||
if (o.timestamp) headers["X-WmailHub-Timestamp"] = o.timestamp;
|
||||
if (o.reuseNonce) headers["X-WmailHub-Nonce"] = o.reuseNonce;
|
||||
if (o.protocol) headers["X-WmailHub-Protocol"] = o.protocol;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
* Uint8Array -> ArrayBuffer(Request body 需要)
|
||||
*/
|
||||
function toArrayBuffer(payload: Uint8Array): ArrayBuffer {
|
||||
return payload.buffer.slice(payload.byteOffset, payload.byteOffset + payload.byteLength) as ArrayBuffer;
|
||||
}
|
||||
|
||||
/** # 函数
|
||||
|
||||
Reference in New Issue
Block a user