- 账号化:节点更名账号,/nodes -> /accounts;添加账号直达 Cloudflare OAuth,自动创建账号并同步账号下全部域名(catch-all 邮箱随同步创建) - 域名:移除手工添加;点击域名『设置 Email Workers』= 部署账号级 Email Worker + Email Routing + catch-all;支持按账号筛选 - Webhook 三级模型:邮箱 -> 域名 -> 全局,收信取第一个命中者;支持自定义请求头与 key(默认 Authorization: Bearer <key>,兼做 X-WmailHub-Signature 签名);独立 webhooks 实体与 /webhooks、/keys 页面删除,Key 管理收进设置模态窗口 - 设置中心:全局 Webhook / 默认 Node Ingress 地址 / 修改密码 / API & MCP Keys(生成、撤销、删除) - UI:Header 标题副标题紧凑化(修复溢出),LOGO 加版本号 tag(v1.1.0 取自 package.json),各列表页搜索框 - 工具链:前端构建 Bun.build -> Vite(vite 8 + @vitejs/plugin-react) - 后端:/api/settings、POST /api/domains/:id/setup、0002 迁移(投递行固化三级目标配置);MCP 去 list_webhooks(5 工具);迁移执行器加 _migrations 记录 - 测试:44/44(新增 settings 套件、webhook 三级优先级/SSRF/签名);UI 冒烟 11/11
135 lines
4.2 KiB
TypeScript
135 lines
4.2 KiB
TypeScript
/**
|
|
* Webhook 配置表单(域名级/邮箱级共用;全局版本在设置模态窗口内)。
|
|
* key 语义:留空保留现有;填空格清除;非空设置新 key。
|
|
*/
|
|
import { useEffect, useState } from "react";
|
|
import { Button, Form, Input, Space, App } from "antd";
|
|
import { putDomainWebhook, putMailboxWebhook } from "../api.ts";
|
|
|
|
/** # 成员变量: 表单当前值(由父组件传入) **/
|
|
export interface WebhookValue {
|
|
url: string | null;
|
|
headers: string | null;
|
|
hasKey: boolean;
|
|
}
|
|
|
|
/** # 函数
|
|
* 解析 headers JSON 串为对象(失败返回 {})
|
|
*/
|
|
export function parseHeaders(s: string | null): Record<string, string> {
|
|
if (!s) return {};
|
|
try {
|
|
const v = JSON.parse(s);
|
|
return v && typeof v === "object" && !Array.isArray(v) ? (v as Record<string, string>) : {};
|
|
} catch {
|
|
return {};
|
|
}
|
|
}
|
|
|
|
/** # 函数
|
|
* Webhook 配置表单组件
|
|
* 参数: scope - "domain" | "mailbox";targetId - 域名/邮箱 ID;value - 当前配置;onSaved - 保存后回调
|
|
*/
|
|
export function WebhookForm({
|
|
scope,
|
|
targetId,
|
|
value,
|
|
onSaved,
|
|
}: {
|
|
scope: "domain" | "mailbox";
|
|
targetId: string;
|
|
value: WebhookValue;
|
|
onSaved: () => void;
|
|
}) {
|
|
const { message } = App.useApp();
|
|
const [form] = Form.useForm<{ url: string; headers: string; key?: string }>();
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
useEffect(() => {
|
|
form.setFieldsValue({
|
|
url: value.url ?? "",
|
|
headers: value.url ? JSON.stringify(parseHeaders(value.headers), null, 2) : "",
|
|
});
|
|
}, [value, form, targetId]);
|
|
|
|
const save = async () => {
|
|
const values = await form.validateFields();
|
|
let headers: Record<string, string> = {};
|
|
if (values.headers.trim()) {
|
|
try {
|
|
headers = JSON.parse(values.headers) as Record<string, string>;
|
|
} catch {
|
|
message.error("自定义请求头必须是合法 JSON 对象");
|
|
return;
|
|
}
|
|
}
|
|
const key = values.key ?? undefined;
|
|
// "填空格清除 key":把单个空格翻译成空串
|
|
const normalizedKey = key !== undefined && key.trim() === "" && key.length > 0 ? "" : key;
|
|
setSaving(true);
|
|
try {
|
|
const body = { url: values.url.trim(), headers, key: normalizedKey };
|
|
if (scope === "domain") await putDomainWebhook(targetId, body);
|
|
else await putMailboxWebhook(targetId, body);
|
|
message.success("Webhook 已保存");
|
|
form.setFieldValue("key", undefined);
|
|
onSaved();
|
|
} catch (err) {
|
|
message.error(err instanceof Error ? err.message : "保存失败");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const clear = async () => {
|
|
setSaving(true);
|
|
try {
|
|
if (scope === "domain") await putDomainWebhook(targetId, { url: "" });
|
|
else await putMailboxWebhook(targetId, { url: "" });
|
|
message.success("已清除该级 Webhook(将回退到上一级配置)");
|
|
form.resetFields();
|
|
onSaved();
|
|
} catch (err) {
|
|
message.error(err instanceof Error ? err.message : "清除失败");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<Form form={form} layout="vertical">
|
|
<Form.Item name="url" label="Webhook URL" extra="留空并保存 = 清除该级配置">
|
|
<Input placeholder="https://hooks.example.com/wmail" allowClear />
|
|
</Form.Item>
|
|
<Form.Item
|
|
name="headers"
|
|
label="自定义请求头(JSON 对象,可选)"
|
|
extra='示例:{"X-Token":"abc"};配置了 key 时默认携带 Authorization: Bearer <key>'
|
|
>
|
|
<Input.TextArea rows={2} placeholder='{"X-Custom":"1"}' />
|
|
</Form.Item>
|
|
<Form.Item
|
|
name="key"
|
|
label="Key"
|
|
extra={
|
|
value.hasKey
|
|
? "已设置 key(****尾号见保存后提示);留空保留,填空格清除"
|
|
: "可选;默认以 Authorization: Bearer <key> 携带,并用于投递签名"
|
|
}
|
|
>
|
|
<Input.Password placeholder="留空保留现有 key" autoComplete="new-password" />
|
|
</Form.Item>
|
|
<Space>
|
|
<Button type="primary" loading={saving} onClick={() => void save()}>
|
|
保存
|
|
</Button>
|
|
{value.url && (
|
|
<Button danger onClick={() => void clear()}>
|
|
清除该级配置
|
|
</Button>
|
|
)}
|
|
</Space>
|
|
</Form>
|
|
);
|
|
}
|