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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user