@dsh-plus/notify-email 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +74 -52
- package/lib/index.js +18 -116
- package/package.json +12 -10
- package/src/client/api.ts +2 -63
- package/src/client/card.tsx +100 -46
- package/src/client/client.ts +26 -7
- package/src/client/scope.ts +33 -0
- package/src/config-api.ts +5 -68
- package/src/config.ts +4 -85
- package/src/ns.ts +9 -0
- package/src/service.ts +2 -2
package/lib/client.js
CHANGED
|
@@ -32,25 +32,25 @@ react = __toESM(react);
|
|
|
32
32
|
let react_jsx_runtime = require("react/jsx-runtime");
|
|
33
33
|
react_jsx_runtime = __toESM(react_jsx_runtime);
|
|
34
34
|
|
|
35
|
+
//#region src/ns.ts
|
|
36
|
+
/**
|
|
37
|
+
* settings 命名空间字面量(纯常量,零依赖,浏览器半可安全引入)。
|
|
38
|
+
* 服务端经 settingsNamespace() 品牌化后使用(config.ts),浏览器半将其作为
|
|
39
|
+
* settings.plugin.item keyed 槽位的 key —— 同一字面量两处共用,防止漂移。
|
|
40
|
+
* 契约:dsh rc7 起该槽位按「卡片编辑的 settings 命名空间」作 key 分发,
|
|
41
|
+
* 官方配置页只渲染 key 命中 Host 已注册命名空间的卡片。
|
|
42
|
+
* @module notify-email/ns
|
|
43
|
+
*/
|
|
44
|
+
const SETTINGS_NS = "dsh-plus-notify-email";
|
|
45
|
+
|
|
46
|
+
//#endregion
|
|
35
47
|
//#region src/client/api.ts
|
|
36
|
-
const ROUTE_CONFIG = "/dsh-plus/notify-email/config";
|
|
37
48
|
const ROUTE_TEST = "/dsh-plus/notify-email/test";
|
|
38
49
|
async function parse(res) {
|
|
39
50
|
const body = await res.json();
|
|
40
51
|
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
|
|
41
52
|
return body;
|
|
42
53
|
}
|
|
43
|
-
async function fetchConfig() {
|
|
44
|
-
return parse(await fetch(ROUTE_CONFIG, { credentials: "same-origin" }));
|
|
45
|
-
}
|
|
46
|
-
async function saveConfig(patch) {
|
|
47
|
-
return parse(await fetch(ROUTE_CONFIG, {
|
|
48
|
-
method: "PUT",
|
|
49
|
-
credentials: "same-origin",
|
|
50
|
-
headers: { "content-type": "application/json" },
|
|
51
|
-
body: JSON.stringify(patch)
|
|
52
|
-
}));
|
|
53
|
-
}
|
|
54
54
|
async function sendTest() {
|
|
55
55
|
return parse(await fetch(ROUTE_TEST, {
|
|
56
56
|
method: "POST",
|
|
@@ -116,20 +116,20 @@ const IDLE_STATUS = {
|
|
|
116
116
|
kind: "idle",
|
|
117
117
|
text: ""
|
|
118
118
|
};
|
|
119
|
-
function
|
|
119
|
+
function draftFromValue(value) {
|
|
120
120
|
return {
|
|
121
|
-
enabled:
|
|
122
|
-
host:
|
|
123
|
-
port: String(
|
|
124
|
-
secure:
|
|
125
|
-
user:
|
|
121
|
+
enabled: value.enabled,
|
|
122
|
+
host: value.smtp.host,
|
|
123
|
+
port: String(value.smtp.port),
|
|
124
|
+
secure: value.smtp.secure,
|
|
125
|
+
user: value.smtp.user,
|
|
126
126
|
pass: "",
|
|
127
|
-
from:
|
|
128
|
-
toText:
|
|
129
|
-
triggers: { ...
|
|
130
|
-
idleDebounceMs: String(
|
|
131
|
-
maxBodyChars: String(
|
|
132
|
-
dryRun:
|
|
127
|
+
from: value.smtp.from,
|
|
128
|
+
toText: value.to.join(", "),
|
|
129
|
+
triggers: { ...value.triggers },
|
|
130
|
+
idleDebounceMs: String(value.idleDebounceMs),
|
|
131
|
+
maxBodyChars: String(value.maxBodyChars),
|
|
132
|
+
dryRun: value.dryRun
|
|
133
133
|
};
|
|
134
134
|
}
|
|
135
135
|
function isPositiveInt(text) {
|
|
@@ -161,41 +161,43 @@ const TRIGGER_KEYS = [
|
|
|
161
161
|
"onPlanReview"
|
|
162
162
|
];
|
|
163
163
|
function NotifyEmailCard(props) {
|
|
164
|
-
const { t } = props;
|
|
164
|
+
const { t, scope, api } = props;
|
|
165
|
+
const snapshot = (0, react.useSyncExternalStore)((listener) => scope.subscribe(listener), () => scope.getSnapshot());
|
|
166
|
+
const value = snapshot.value;
|
|
165
167
|
const [open, setOpen] = (0, react.useState)(false);
|
|
166
|
-
const [wire, setWire] = (0, react.useState)(null);
|
|
167
168
|
const [draft, setDraft] = (0, react.useState)(null);
|
|
168
|
-
const [
|
|
169
|
+
const [passConfigured, setPassConfigured] = (0, react.useState)(false);
|
|
169
170
|
const [saving, setSaving] = (0, react.useState)(false);
|
|
170
171
|
const [testing, setTesting] = (0, react.useState)(false);
|
|
171
172
|
const [status, setStatus] = (0, react.useState)(IDLE_STATUS);
|
|
173
|
+
(0, react.useEffect)(() => {
|
|
174
|
+
if (value === void 0 || draft !== null) return;
|
|
175
|
+
setDraft(draftFromValue(value));
|
|
176
|
+
}, [value, draft]);
|
|
172
177
|
(0, react.useEffect)(() => {
|
|
173
178
|
let alive = true;
|
|
174
|
-
|
|
175
|
-
if (!alive) return;
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
}).catch(() => {
|
|
179
|
-
if (alive) setFailed(true);
|
|
180
|
-
});
|
|
179
|
+
api.describe().then((response) => {
|
|
180
|
+
if (!alive || !response.result.ok) return;
|
|
181
|
+
const view = response.result.value?.namespaces.find((ns) => ns.ns === SETTINGS_NS);
|
|
182
|
+
setPassConfigured(view?.secrets.some((secret) => secret.path.join(".") === "smtp.pass" && secret.set) ?? false);
|
|
183
|
+
}).catch(() => {});
|
|
181
184
|
return () => {
|
|
182
185
|
alive = false;
|
|
183
186
|
};
|
|
184
|
-
}, []);
|
|
185
|
-
const dirty = (0, react.useMemo)(() =>
|
|
187
|
+
}, [api, snapshot]);
|
|
188
|
+
const dirty = (0, react.useMemo)(() => value !== void 0 && draft !== null && JSON.stringify(toPatch(draft)) !== JSON.stringify(toPatch(draftFromValue(value))), [value, draft]);
|
|
186
189
|
const invalid = (0, react.useMemo)(() => draft !== null && (!isPositiveInt(draft.port) || !isPositiveInt(draft.idleDebounceMs) || !isPositiveInt(draft.maxBodyChars) || Number(draft.maxBodyChars) < 200), [draft]);
|
|
187
|
-
if (
|
|
188
|
-
if (wire === null || draft === null) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", {
|
|
190
|
+
if (value === void 0 || draft === null) return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("li", {
|
|
189
191
|
className: "dne-card",
|
|
190
192
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
191
193
|
className: "dne-readOnly",
|
|
192
194
|
children: t("loading")
|
|
193
195
|
})
|
|
194
196
|
});
|
|
195
|
-
const edit = (key,
|
|
197
|
+
const edit = (key, editValue) => {
|
|
196
198
|
setDraft({
|
|
197
199
|
...draft,
|
|
198
|
-
[key]:
|
|
200
|
+
[key]: editValue
|
|
199
201
|
});
|
|
200
202
|
setStatus(IDLE_STATUS);
|
|
201
203
|
};
|
|
@@ -207,14 +209,24 @@ function NotifyEmailCard(props) {
|
|
|
207
209
|
};
|
|
208
210
|
const onSave = () => {
|
|
209
211
|
setSaving(true);
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
212
|
+
const patch = toPatch(draft);
|
|
213
|
+
if (patch["smtp"]["pass"] === "") delete patch["smtp"]["pass"];
|
|
214
|
+
const revision = scope.getSnapshot().revision;
|
|
215
|
+
api.update({
|
|
216
|
+
ns: SETTINGS_NS,
|
|
217
|
+
patch,
|
|
218
|
+
...revision !== void 0 ? { expectedRevision: revision } : {}
|
|
219
|
+
}).then(async (response) => {
|
|
220
|
+
if (!response.result.ok) throw new Error(response.result.error?.message ?? t("saveFailed"));
|
|
221
|
+
await scope.load();
|
|
222
|
+
const next = scope.getSnapshot().value;
|
|
223
|
+
if (next !== void 0) setDraft(draftFromValue(next));
|
|
213
224
|
setStatus(IDLE_STATUS);
|
|
214
225
|
}).catch((error) => {
|
|
226
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
215
227
|
setStatus({
|
|
216
228
|
kind: "error",
|
|
217
|
-
text: `${t("saveFailed")}
|
|
229
|
+
text: `${t("saveFailed")}${message}`
|
|
218
230
|
});
|
|
219
231
|
}).finally(() => setSaving(false));
|
|
220
232
|
};
|
|
@@ -239,7 +251,7 @@ function NotifyEmailCard(props) {
|
|
|
239
251
|
});
|
|
240
252
|
}).finally(() => setTesting(false));
|
|
241
253
|
};
|
|
242
|
-
const disabled = !
|
|
254
|
+
const disabled = !snapshot.writable;
|
|
243
255
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
|
|
244
256
|
className: `dne-card${open ? " dne-cardOpen" : ""}`,
|
|
245
257
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
@@ -325,8 +337,8 @@ function NotifyEmailCard(props) {
|
|
|
325
337
|
password: true,
|
|
326
338
|
disabled,
|
|
327
339
|
badge: {
|
|
328
|
-
text:
|
|
329
|
-
set:
|
|
340
|
+
text: passConfigured ? t("passSet") : t("passUnset"),
|
|
341
|
+
set: passConfigured
|
|
330
342
|
},
|
|
331
343
|
onEdit: (v) => edit("pass", v)
|
|
332
344
|
}),
|
|
@@ -406,7 +418,7 @@ function NotifyEmailCard(props) {
|
|
|
406
418
|
className: "dne-btn dne-btnGhost",
|
|
407
419
|
disabled: !dirty || saving,
|
|
408
420
|
onClick: () => {
|
|
409
|
-
setDraft(
|
|
421
|
+
setDraft(draftFromValue(value));
|
|
410
422
|
setStatus(IDLE_STATUS);
|
|
411
423
|
},
|
|
412
424
|
children: t("discard")
|
|
@@ -595,7 +607,12 @@ function injectStyle() {
|
|
|
595
607
|
//#region src/client/client.ts
|
|
596
608
|
const name = "dsh-plus-notify-email";
|
|
597
609
|
/** 浏览器半需要的 cordis 服务 key(loader 据此注入;package.json 的 dsh.client.inject 管包加载顺序)。 */
|
|
598
|
-
const inject = [
|
|
610
|
+
const inject = [
|
|
611
|
+
"slots",
|
|
612
|
+
"locale",
|
|
613
|
+
"settingsScope",
|
|
614
|
+
"connection"
|
|
615
|
+
];
|
|
599
616
|
function apply(ctx) {
|
|
600
617
|
const c = ctx;
|
|
601
618
|
const tag = injectStyle();
|
|
@@ -606,12 +623,17 @@ function apply(ctx) {
|
|
|
606
623
|
zh,
|
|
607
624
|
en
|
|
608
625
|
}), "notify-email: locale");
|
|
626
|
+
const scope = c.settingsScope.bind({ namespace: SETTINGS_NS });
|
|
627
|
+
const api = c.get("connection").api.settings;
|
|
609
628
|
c.slots.inject("settings.plugin.item", () => c.slots.register({
|
|
610
629
|
name: "settings.plugin.item",
|
|
611
|
-
|
|
612
|
-
order: 100,
|
|
630
|
+
key: SETTINGS_NS,
|
|
613
631
|
locale: NS,
|
|
614
|
-
inject: () => ({
|
|
632
|
+
inject: () => ({
|
|
633
|
+
t: c.locale.bind(NS),
|
|
634
|
+
scope,
|
|
635
|
+
api
|
|
636
|
+
})
|
|
615
637
|
}, NotifyEmailCard));
|
|
616
638
|
}
|
|
617
639
|
|
package/lib/index.js
CHANGED
|
@@ -6,9 +6,21 @@ import { appendFile, mkdir } from "node:fs/promises";
|
|
|
6
6
|
import { dirname } from "node:path";
|
|
7
7
|
import nodemailer from "nodemailer";
|
|
8
8
|
|
|
9
|
+
//#region src/ns.ts
|
|
10
|
+
/**
|
|
11
|
+
* settings 命名空间字面量(纯常量,零依赖,浏览器半可安全引入)。
|
|
12
|
+
* 服务端经 settingsNamespace() 品牌化后使用(config.ts),浏览器半将其作为
|
|
13
|
+
* settings.plugin.item keyed 槽位的 key —— 同一字面量两处共用,防止漂移。
|
|
14
|
+
* 契约:dsh rc7 起该槽位按「卡片编辑的 settings 命名空间」作 key 分发,
|
|
15
|
+
* 官方配置页只渲染 key 命中 Host 已注册命名空间的卡片。
|
|
16
|
+
* @module notify-email/ns
|
|
17
|
+
*/
|
|
18
|
+
const SETTINGS_NS$1 = "dsh-plus-notify-email";
|
|
19
|
+
|
|
20
|
+
//#endregion
|
|
9
21
|
//#region src/config.ts
|
|
10
|
-
/** settings 命名空间;webui
|
|
11
|
-
const SETTINGS_NS = settingsNamespace(
|
|
22
|
+
/** settings 命名空间;webui 配置卡片与插件运行期读取同一份(字面量见 ./ns.ts)。 */
|
|
23
|
+
const SETTINGS_NS = settingsNamespace(SETTINGS_NS$1);
|
|
12
24
|
const SmtpSchema = z.object({
|
|
13
25
|
host: z.string().description("SMTP 服务器主机名").default(""),
|
|
14
26
|
port: z.natural().max(65535).description("SMTP 端口").default(465),
|
|
@@ -46,64 +58,6 @@ const Config = z.object({
|
|
|
46
58
|
maxBodyChars: z.natural().min(200).description("邮件正文内容截断长度").default(4e3),
|
|
47
59
|
dryRun: z.boolean().description("仅记录日志不真实发送(开发调试用)").default(false)
|
|
48
60
|
});
|
|
49
|
-
function toWire(cfg, writable) {
|
|
50
|
-
return {
|
|
51
|
-
enabled: cfg.enabled,
|
|
52
|
-
smtp: {
|
|
53
|
-
host: cfg.smtp.host,
|
|
54
|
-
port: cfg.smtp.port,
|
|
55
|
-
secure: cfg.smtp.secure,
|
|
56
|
-
user: cfg.smtp.user,
|
|
57
|
-
from: cfg.smtp.from,
|
|
58
|
-
passConfigured: cfg.smtp.pass.length > 0
|
|
59
|
-
},
|
|
60
|
-
to: [...cfg.to],
|
|
61
|
-
triggers: { ...cfg.triggers },
|
|
62
|
-
idleDebounceMs: cfg.idleDebounceMs,
|
|
63
|
-
maxBodyChars: cfg.maxBodyChars,
|
|
64
|
-
dryRun: cfg.dryRun,
|
|
65
|
-
writable
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
/**
|
|
69
|
-
* 配置卡片写回用的 patch schema:字段全部可选(无默认值),
|
|
70
|
-
* 只携带用户真实编辑过的字段,交给 settings.update 深合并进用户层。
|
|
71
|
-
*/
|
|
72
|
-
const WirePatch = z.object({
|
|
73
|
-
enabled: z.boolean(),
|
|
74
|
-
smtp: z.object({
|
|
75
|
-
host: z.string(),
|
|
76
|
-
port: z.natural().max(65535),
|
|
77
|
-
secure: z.boolean(),
|
|
78
|
-
user: z.string(),
|
|
79
|
-
pass: z.string(),
|
|
80
|
-
from: z.string()
|
|
81
|
-
}),
|
|
82
|
-
to: z.array(z.string()),
|
|
83
|
-
triggers: z.object({
|
|
84
|
-
onComplete: z.boolean(),
|
|
85
|
-
onError: z.boolean(),
|
|
86
|
-
onAborted: z.boolean(),
|
|
87
|
-
onQuestion: z.boolean(),
|
|
88
|
-
onPlanReview: z.boolean()
|
|
89
|
-
}),
|
|
90
|
-
idleDebounceMs: z.natural(),
|
|
91
|
-
maxBodyChars: z.natural().min(200),
|
|
92
|
-
dryRun: z.boolean()
|
|
93
|
-
});
|
|
94
|
-
/**
|
|
95
|
-
* 把卡片提交的 patch 规整为 settings.update 的用户层 patch。
|
|
96
|
-
* smtp.pass 为空串表示「保持不变」,从 patch 中剔除,避免覆盖已存密码。
|
|
97
|
-
*/
|
|
98
|
-
function toUserPatch(input) {
|
|
99
|
-
const patch = { ...input };
|
|
100
|
-
if (input.smtp !== void 0 && input.smtp !== null) {
|
|
101
|
-
const smtp = { ...input.smtp };
|
|
102
|
-
if (smtp["pass"] === "") delete smtp["pass"];
|
|
103
|
-
patch["smtp"] = smtp;
|
|
104
|
-
}
|
|
105
|
-
return patch;
|
|
106
|
-
}
|
|
107
61
|
|
|
108
62
|
//#endregion
|
|
109
63
|
//#region src/audit.ts
|
|
@@ -209,64 +163,17 @@ var Mailer = class {
|
|
|
209
163
|
|
|
210
164
|
//#endregion
|
|
211
165
|
//#region src/config-api.ts
|
|
212
|
-
const ROUTE_CONFIG = "/dsh-plus/notify-email/config";
|
|
213
166
|
const ROUTE_TEST = "/dsh-plus/notify-email/test";
|
|
214
|
-
const MAX_BODY_BYTES = 64 * 1024;
|
|
215
167
|
function sendJson(res, status, body) {
|
|
216
168
|
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
217
169
|
res.end(JSON.stringify(body));
|
|
218
170
|
}
|
|
219
|
-
function readBody(req) {
|
|
220
|
-
return new Promise((resolve, reject) => {
|
|
221
|
-
const chunks = [];
|
|
222
|
-
let size = 0;
|
|
223
|
-
req.on("data", (chunk) => {
|
|
224
|
-
size += chunk.length;
|
|
225
|
-
if (size > MAX_BODY_BYTES) {
|
|
226
|
-
reject(/* @__PURE__ */ new Error("request body too large"));
|
|
227
|
-
req.destroy();
|
|
228
|
-
return;
|
|
229
|
-
}
|
|
230
|
-
chunks.push(chunk);
|
|
231
|
-
});
|
|
232
|
-
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
233
|
-
req.on("error", reject);
|
|
234
|
-
});
|
|
235
|
-
}
|
|
236
|
-
async function readPatch(req) {
|
|
237
|
-
const raw = await readBody(req);
|
|
238
|
-
let parsed;
|
|
239
|
-
try {
|
|
240
|
-
parsed = JSON.parse(raw);
|
|
241
|
-
} catch {
|
|
242
|
-
throw new Error("request body is not valid JSON");
|
|
243
|
-
}
|
|
244
|
-
return WirePatch(parsed);
|
|
245
|
-
}
|
|
246
|
-
async function handleConfig(ctx, service, req, res) {
|
|
247
|
-
const settings = ctx.get("settings");
|
|
248
|
-
if (req.method === "GET") {
|
|
249
|
-
sendJson(res, 200, toWire(service.currentConfig(), settings !== void 0));
|
|
250
|
-
return;
|
|
251
|
-
}
|
|
252
|
-
if (req.method !== "PUT") {
|
|
253
|
-
sendJson(res, 405, { error: "method not allowed" });
|
|
254
|
-
return;
|
|
255
|
-
}
|
|
256
|
-
if (settings === void 0) {
|
|
257
|
-
sendJson(res, 503, { error: "settings provider 不可用,无法在线保存;请编辑 settings.yaml" });
|
|
258
|
-
return;
|
|
259
|
-
}
|
|
260
|
-
const patch = toUserPatch(await readPatch(req));
|
|
261
|
-
await settings.update(SETTINGS_NS, patch);
|
|
262
|
-
sendJson(res, 200, toWire(service.currentConfig(), true));
|
|
263
|
-
}
|
|
264
171
|
async function handleTest(service, res) {
|
|
265
172
|
const result = await service.sendTest();
|
|
266
173
|
sendJson(res, result.ok ? 200 : 502, result);
|
|
267
174
|
}
|
|
268
|
-
/**
|
|
269
|
-
function
|
|
175
|
+
/** 注册测试发信路由;返回是否注册成功(webServer 缺失时由调用方保证不调用)。 */
|
|
176
|
+
function registerTestApi(ctx, service) {
|
|
270
177
|
const logger = ctx.logger("notify-email");
|
|
271
178
|
const guard = (handler) => {
|
|
272
179
|
return async (req, res) => {
|
|
@@ -274,17 +181,12 @@ function registerConfigApi(ctx, service) {
|
|
|
274
181
|
await handler(req, res);
|
|
275
182
|
} catch (error) {
|
|
276
183
|
const message = error instanceof Error ? error.message : String(error);
|
|
277
|
-
logger.warn(`
|
|
184
|
+
logger.warn(`test api ${req.method ?? "?"} ${req.url ?? "?"} failed: ${message}`);
|
|
278
185
|
if (!res.headersSent) sendJson(res, 400, { error: message });
|
|
279
186
|
else res.end();
|
|
280
187
|
}
|
|
281
188
|
};
|
|
282
189
|
};
|
|
283
|
-
ctx.webServer.register({
|
|
284
|
-
kind: "exact",
|
|
285
|
-
path: ROUTE_CONFIG,
|
|
286
|
-
handler: guard((req, res) => handleConfig(ctx, service, req, res))
|
|
287
|
-
});
|
|
288
190
|
ctx.webServer.register({
|
|
289
191
|
kind: "exact",
|
|
290
192
|
path: ROUTE_TEST,
|
|
@@ -519,7 +421,7 @@ var NotifyEmailService = class extends Service {
|
|
|
519
421
|
installDecisionWatcher(ctx, this);
|
|
520
422
|
installTurnEndWatcher(ctx, this, () => this.current());
|
|
521
423
|
ctx.inject(["webServer"], (webCtx) => {
|
|
522
|
-
|
|
424
|
+
registerTestApi(webCtx, this);
|
|
523
425
|
});
|
|
524
426
|
}
|
|
525
427
|
/** 注册触发器(第三方扩展接口);返回注销函数,随调用方 fiber 释放。 */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dsh-plus/notify-email",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"description": "dsh-plus service+ui plugin: 任务结束/等待决策/出错时向指定邮箱发送邮件通知",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -24,24 +24,26 @@
|
|
|
24
24
|
"client": {
|
|
25
25
|
"inject": [
|
|
26
26
|
"@deepseek-ai/dsh-client-runtime",
|
|
27
|
-
"@deepseek-ai/dsh-client-locale"
|
|
27
|
+
"@deepseek-ai/dsh-client-locale",
|
|
28
|
+
"@deepseek-ai/dsh-client-connection",
|
|
29
|
+
"@deepseek-ai/dsh-client-ui-settings"
|
|
28
30
|
],
|
|
29
31
|
"platform": "web"
|
|
30
32
|
}
|
|
31
33
|
},
|
|
32
34
|
"dependencies": {
|
|
33
35
|
"@deepseek-ai/cordis": "4.0.1",
|
|
34
|
-
"@deepseek-ai/dsh-home-paths": "0.1.0-rc.
|
|
35
|
-
"@deepseek-ai/dsh-settings": "0.1.0-rc.
|
|
36
|
+
"@deepseek-ai/dsh-home-paths": "0.1.0-rc.7",
|
|
37
|
+
"@deepseek-ai/dsh-settings": "0.1.0-rc.7",
|
|
36
38
|
"@deepseek-ai/schemastery": "3.18.1",
|
|
37
39
|
"nodemailer": "9.0.5"
|
|
38
40
|
},
|
|
39
41
|
"devDependencies": {
|
|
40
|
-
"@deepseek-ai/dsh-agent": "0.1.0-rc.
|
|
41
|
-
"@deepseek-ai/dsh-host-webserver": "0.1.0-rc.
|
|
42
|
-
"@deepseek-ai/dsh-llm": "0.1.0-rc.
|
|
43
|
-
"@deepseek-ai/dsh-session": "0.1.0-rc.
|
|
44
|
-
"@deepseek-ai/dsh-tools": "0.1.0-rc.
|
|
42
|
+
"@deepseek-ai/dsh-agent": "0.1.0-rc.7",
|
|
43
|
+
"@deepseek-ai/dsh-host-webserver": "0.1.0-rc.7",
|
|
44
|
+
"@deepseek-ai/dsh-llm": "0.1.0-rc.7",
|
|
45
|
+
"@deepseek-ai/dsh-session": "0.1.0-rc.7",
|
|
46
|
+
"@deepseek-ai/dsh-tools": "0.1.0-rc.7",
|
|
45
47
|
"@types/nodemailer": "8.0.1",
|
|
46
48
|
"@types/react": "~18.3.1",
|
|
47
49
|
"react": "^18.2.0"
|
|
@@ -52,7 +54,7 @@
|
|
|
52
54
|
"license": "MIT",
|
|
53
55
|
"repository": {
|
|
54
56
|
"type": "git",
|
|
55
|
-
"url": "git+https://github.com/A-G-guy/dsh-
|
|
57
|
+
"url": "git+https://github.com/A-G-guy/dsh-plus.git",
|
|
56
58
|
"directory": "packages/notify-email"
|
|
57
59
|
},
|
|
58
60
|
"scripts": {
|
package/src/client/api.ts
CHANGED
|
@@ -1,60 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* 自定义端点通道:仅剩「发送测试邮件」(配置读写已迁移到官方 settingsScope
|
|
3
|
+
* 传输,见 scope.ts 与 card.tsx)。
|
|
4
4
|
* @module notify-email/client/api
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
-
export interface WireSmtp {
|
|
8
|
-
host: string
|
|
9
|
-
port: number
|
|
10
|
-
secure: boolean
|
|
11
|
-
user: string
|
|
12
|
-
from: string
|
|
13
|
-
passConfigured: boolean
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
export interface WireTriggers {
|
|
17
|
-
onComplete: boolean
|
|
18
|
-
onError: boolean
|
|
19
|
-
onAborted: boolean
|
|
20
|
-
onQuestion: boolean
|
|
21
|
-
onPlanReview: boolean
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
export interface WireConfig {
|
|
25
|
-
enabled: boolean
|
|
26
|
-
smtp: WireSmtp
|
|
27
|
-
to: string[]
|
|
28
|
-
triggers: WireTriggers
|
|
29
|
-
idleDebounceMs: number
|
|
30
|
-
maxBodyChars: number
|
|
31
|
-
dryRun: boolean
|
|
32
|
-
writable: boolean
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export interface WirePatch {
|
|
36
|
-
enabled: boolean
|
|
37
|
-
smtp: {
|
|
38
|
-
host: string
|
|
39
|
-
port: number
|
|
40
|
-
secure: boolean
|
|
41
|
-
user: string
|
|
42
|
-
pass: string
|
|
43
|
-
from: string
|
|
44
|
-
}
|
|
45
|
-
to: string[]
|
|
46
|
-
triggers: WireTriggers
|
|
47
|
-
idleDebounceMs: number
|
|
48
|
-
maxBodyChars: number
|
|
49
|
-
dryRun: boolean
|
|
50
|
-
}
|
|
51
|
-
|
|
52
7
|
export interface TestResult {
|
|
53
8
|
ok: boolean
|
|
54
9
|
detail: string
|
|
55
10
|
}
|
|
56
11
|
|
|
57
|
-
const ROUTE_CONFIG = '/dsh-plus/notify-email/config'
|
|
58
12
|
const ROUTE_TEST = '/dsh-plus/notify-email/test'
|
|
59
13
|
|
|
60
14
|
async function parse<T>(res: Response): Promise<T> {
|
|
@@ -63,21 +17,6 @@ async function parse<T>(res: Response): Promise<T> {
|
|
|
63
17
|
return body
|
|
64
18
|
}
|
|
65
19
|
|
|
66
|
-
export async function fetchConfig(): Promise<WireConfig> {
|
|
67
|
-
return parse<WireConfig>(await fetch(ROUTE_CONFIG, { credentials: 'same-origin' }))
|
|
68
|
-
}
|
|
69
|
-
|
|
70
|
-
export async function saveConfig(patch: WirePatch): Promise<WireConfig> {
|
|
71
|
-
return parse<WireConfig>(
|
|
72
|
-
await fetch(ROUTE_CONFIG, {
|
|
73
|
-
method: 'PUT',
|
|
74
|
-
credentials: 'same-origin',
|
|
75
|
-
headers: { 'content-type': 'application/json' },
|
|
76
|
-
body: JSON.stringify(patch),
|
|
77
|
-
}),
|
|
78
|
-
)
|
|
79
|
-
}
|
|
80
|
-
|
|
81
20
|
export async function sendTest(): Promise<TestResult> {
|
|
82
21
|
return parse<TestResult>(await fetch(ROUTE_TEST, { method: 'POST', credentials: 'same-origin' }))
|
|
83
22
|
}
|
package/src/client/card.tsx
CHANGED
|
@@ -1,18 +1,46 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* 「邮件通知」配置卡片:注册进 settings.plugin.item 插槽(官方插件配置页)。
|
|
3
3
|
* 交互对齐官方卡片:折叠/展开、staged draft、未保存标记、保存/放弃;
|
|
4
|
-
*
|
|
4
|
+
* 另加「发送测试邮件」(唯一保留的自定义端点)。
|
|
5
|
+
* 配置读写走官方 settingsScope 传输(scope.ts):value 为 schema 解析后的
|
|
6
|
+
* 脱敏视图(smtp.pass 不出现,passConfigured 由 describe 的 secrets 探测),
|
|
7
|
+
* 保存经 settings.update 深合并(空 pass 剔除 = 保持不变)。
|
|
5
8
|
* @module notify-email/client/card
|
|
6
9
|
*/
|
|
7
|
-
import { useEffect, useMemo, useState, type ReactElement } from 'react'
|
|
10
|
+
import { useEffect, useMemo, useSyncExternalStore, useState, type ReactElement } from 'react'
|
|
8
11
|
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
} from './api.ts'
|
|
12
|
+
import { SETTINGS_NS } from '../ns.ts'
|
|
13
|
+
import { sendTest } from './api.ts'
|
|
12
14
|
import { CheckRow, TextField } from './fields.tsx'
|
|
15
|
+
import type { Scope, SettingsApi } from './scope.ts'
|
|
13
16
|
|
|
14
17
|
export interface CardProps {
|
|
15
18
|
t(key: string): string
|
|
19
|
+
scope: Scope
|
|
20
|
+
api: SettingsApi
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** settings 命名空间的脱敏解析值(smtp.pass 被脱敏剥除)。 */
|
|
24
|
+
export interface ConfigValue {
|
|
25
|
+
enabled: boolean
|
|
26
|
+
smtp: {
|
|
27
|
+
host: string
|
|
28
|
+
port: number
|
|
29
|
+
secure: boolean
|
|
30
|
+
user: string
|
|
31
|
+
from: string
|
|
32
|
+
}
|
|
33
|
+
to: string[]
|
|
34
|
+
triggers: {
|
|
35
|
+
onComplete: boolean
|
|
36
|
+
onError: boolean
|
|
37
|
+
onAborted: boolean
|
|
38
|
+
onQuestion: boolean
|
|
39
|
+
onPlanReview: boolean
|
|
40
|
+
}
|
|
41
|
+
idleDebounceMs: number
|
|
42
|
+
maxBodyChars: number
|
|
43
|
+
dryRun: boolean
|
|
16
44
|
}
|
|
17
45
|
|
|
18
46
|
interface Draft {
|
|
@@ -24,7 +52,7 @@ interface Draft {
|
|
|
24
52
|
pass: string
|
|
25
53
|
from: string
|
|
26
54
|
toText: string
|
|
27
|
-
triggers:
|
|
55
|
+
triggers: ConfigValue['triggers']
|
|
28
56
|
idleDebounceMs: string
|
|
29
57
|
maxBodyChars: string
|
|
30
58
|
dryRun: boolean
|
|
@@ -37,20 +65,20 @@ interface Status {
|
|
|
37
65
|
|
|
38
66
|
const IDLE_STATUS: Status = { kind: 'idle', text: '' }
|
|
39
67
|
|
|
40
|
-
function
|
|
68
|
+
function draftFromValue(value: ConfigValue): Draft {
|
|
41
69
|
return {
|
|
42
|
-
enabled:
|
|
43
|
-
host:
|
|
44
|
-
port: String(
|
|
45
|
-
secure:
|
|
46
|
-
user:
|
|
70
|
+
enabled: value.enabled,
|
|
71
|
+
host: value.smtp.host,
|
|
72
|
+
port: String(value.smtp.port),
|
|
73
|
+
secure: value.smtp.secure,
|
|
74
|
+
user: value.smtp.user,
|
|
47
75
|
pass: '',
|
|
48
|
-
from:
|
|
49
|
-
toText:
|
|
50
|
-
triggers: { ...
|
|
51
|
-
idleDebounceMs: String(
|
|
52
|
-
maxBodyChars: String(
|
|
53
|
-
dryRun:
|
|
76
|
+
from: value.smtp.from,
|
|
77
|
+
toText: value.to.join(', '),
|
|
78
|
+
triggers: { ...value.triggers },
|
|
79
|
+
idleDebounceMs: String(value.idleDebounceMs),
|
|
80
|
+
maxBodyChars: String(value.maxBodyChars),
|
|
81
|
+
dryRun: value.dryRun,
|
|
54
82
|
}
|
|
55
83
|
}
|
|
56
84
|
|
|
@@ -58,7 +86,7 @@ function isPositiveInt(text: string): boolean {
|
|
|
58
86
|
return /^[0-9]+$/.test(text) && Number(text) > 0
|
|
59
87
|
}
|
|
60
88
|
|
|
61
|
-
function toPatch(draft: Draft):
|
|
89
|
+
function toPatch(draft: Draft): Record<string, unknown> {
|
|
62
90
|
return {
|
|
63
91
|
enabled: draft.enabled,
|
|
64
92
|
smtp: {
|
|
@@ -80,34 +108,47 @@ function toPatch(draft: Draft): WirePatch {
|
|
|
80
108
|
const TRIGGER_KEYS = ['onComplete', 'onError', 'onAborted', 'onQuestion', 'onPlanReview'] as const
|
|
81
109
|
|
|
82
110
|
export function NotifyEmailCard(props: CardProps): ReactElement | null {
|
|
83
|
-
const { t } = props
|
|
111
|
+
const { t, scope, api } = props
|
|
112
|
+
const snapshot = useSyncExternalStore(
|
|
113
|
+
(listener: () => void) => scope.subscribe(listener),
|
|
114
|
+
() => scope.getSnapshot(),
|
|
115
|
+
)
|
|
116
|
+
const value = snapshot.value as ConfigValue | undefined
|
|
84
117
|
const [open, setOpen] = useState(false)
|
|
85
|
-
const [wire, setWire] = useState<WireConfig | null>(null)
|
|
86
118
|
const [draft, setDraft] = useState<Draft | null>(null)
|
|
87
|
-
const [
|
|
119
|
+
const [passConfigured, setPassConfigured] = useState(false)
|
|
88
120
|
const [saving, setSaving] = useState(false)
|
|
89
121
|
const [testing, setTesting] = useState(false)
|
|
90
122
|
const [status, setStatus] = useState<Status>(IDLE_STATUS)
|
|
91
123
|
|
|
124
|
+
// 首次拿到解析值后播种草稿;后续 Host 更新不覆盖在途编辑(与官方 staged 表单一致)。
|
|
125
|
+
useEffect(() => {
|
|
126
|
+
if (value === undefined || draft !== null) return
|
|
127
|
+
setDraft(draftFromValue(value))
|
|
128
|
+
}, [value, draft])
|
|
129
|
+
|
|
130
|
+
// passConfigured 探测:scope 快照不含 secrets,经 describe 的 secrets 列表判断。
|
|
92
131
|
useEffect(() => {
|
|
93
132
|
let alive = true
|
|
94
|
-
|
|
95
|
-
.then((
|
|
96
|
-
if (!alive) return
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
if (alive) setFailed(true)
|
|
133
|
+
api.describe()
|
|
134
|
+
.then((response) => {
|
|
135
|
+
if (!alive || !response.result.ok) return
|
|
136
|
+
const view = response.result.value?.namespaces.find((ns) => ns.ns === SETTINGS_NS)
|
|
137
|
+
setPassConfigured(
|
|
138
|
+
view?.secrets.some((secret) => secret.path.join('.') === 'smtp.pass' && secret.set) ?? false,
|
|
139
|
+
)
|
|
102
140
|
})
|
|
141
|
+
.catch(() => {})
|
|
103
142
|
return () => {
|
|
104
143
|
alive = false
|
|
105
144
|
}
|
|
106
|
-
}, [])
|
|
145
|
+
}, [api, snapshot])
|
|
107
146
|
|
|
108
147
|
const dirty = useMemo(
|
|
109
|
-
() =>
|
|
110
|
-
|
|
148
|
+
() =>
|
|
149
|
+
value !== undefined && draft !== null &&
|
|
150
|
+
JSON.stringify(toPatch(draft)) !== JSON.stringify(toPatch(draftFromValue(value))),
|
|
151
|
+
[value, draft],
|
|
111
152
|
)
|
|
112
153
|
const invalid = useMemo(
|
|
113
154
|
() =>
|
|
@@ -117,27 +158,40 @@ export function NotifyEmailCard(props: CardProps): ReactElement | null {
|
|
|
117
158
|
[draft],
|
|
118
159
|
)
|
|
119
160
|
|
|
120
|
-
if (
|
|
121
|
-
if (wire === null || draft === null) {
|
|
161
|
+
if (value === undefined || draft === null) {
|
|
122
162
|
return <li className="dne-card"><p className="dne-readOnly">{t('loading')}</p></li>
|
|
123
163
|
}
|
|
124
|
-
const edit = <K extends keyof Draft>(key: K,
|
|
125
|
-
setDraft({ ...draft, [key]:
|
|
164
|
+
const edit = <K extends keyof Draft>(key: K, editValue: Draft[K]): void => {
|
|
165
|
+
setDraft({ ...draft, [key]: editValue })
|
|
126
166
|
setStatus(IDLE_STATUS)
|
|
127
167
|
}
|
|
128
|
-
const editTrigger = (key:
|
|
168
|
+
const editTrigger = (key: (typeof TRIGGER_KEYS)[number], checked: boolean): void => {
|
|
129
169
|
edit('triggers', { ...draft.triggers, [key]: checked })
|
|
130
170
|
}
|
|
131
171
|
const onSave = (): void => {
|
|
132
172
|
setSaving(true)
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
173
|
+
const patch = toPatch(draft)
|
|
174
|
+
if ((patch['smtp'] as Record<string, unknown>)['pass'] === '') {
|
|
175
|
+
delete (patch['smtp'] as Record<string, unknown>)['pass']
|
|
176
|
+
}
|
|
177
|
+
const revision = scope.getSnapshot().revision
|
|
178
|
+
api.update({
|
|
179
|
+
ns: SETTINGS_NS,
|
|
180
|
+
patch,
|
|
181
|
+
...(revision !== undefined ? { expectedRevision: revision } : {}),
|
|
182
|
+
})
|
|
183
|
+
.then(async (response) => {
|
|
184
|
+
if (!response.result.ok) {
|
|
185
|
+
throw new Error(response.result.error?.message ?? t('saveFailed'))
|
|
186
|
+
}
|
|
187
|
+
await scope.load()
|
|
188
|
+
const next = scope.getSnapshot().value as ConfigValue | undefined
|
|
189
|
+
if (next !== undefined) setDraft(draftFromValue(next))
|
|
137
190
|
setStatus(IDLE_STATUS)
|
|
138
191
|
})
|
|
139
192
|
.catch((error: unknown) => {
|
|
140
|
-
|
|
193
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
194
|
+
setStatus({ kind: 'error', text: `${t('saveFailed')}${message}` })
|
|
141
195
|
})
|
|
142
196
|
.finally(() => setSaving(false))
|
|
143
197
|
}
|
|
@@ -158,7 +212,7 @@ export function NotifyEmailCard(props: CardProps): ReactElement | null {
|
|
|
158
212
|
.finally(() => setTesting(false))
|
|
159
213
|
}
|
|
160
214
|
|
|
161
|
-
const disabled = !
|
|
215
|
+
const disabled = !snapshot.writable
|
|
162
216
|
return (
|
|
163
217
|
<li className={`dne-card${open ? ' dne-cardOpen' : ''}`}>
|
|
164
218
|
<button
|
|
@@ -191,7 +245,7 @@ export function NotifyEmailCard(props: CardProps): ReactElement | null {
|
|
|
191
245
|
disabled={disabled} onEdit={(v) => edit('user', v)} />
|
|
192
246
|
<TextField id="dne-pass" label={t('pass')} hint={t('passHint')} value={draft.pass} password
|
|
193
247
|
disabled={disabled}
|
|
194
|
-
badge={{ text:
|
|
248
|
+
badge={{ text: passConfigured ? t('passSet') : t('passUnset'), set: passConfigured }}
|
|
195
249
|
onEdit={(v) => edit('pass', v)} />
|
|
196
250
|
<TextField id="dne-from" label={t('from')} hint={t('fromHint')} value={draft.from}
|
|
197
251
|
disabled={disabled} onEdit={(v) => edit('from', v)} />
|
|
@@ -224,7 +278,7 @@ export function NotifyEmailCard(props: CardProps): ReactElement | null {
|
|
|
224
278
|
{t(testing ? 'testing' : 'test')}
|
|
225
279
|
</button>
|
|
226
280
|
<button type="button" className="dne-btn dne-btnGhost" disabled={!dirty || saving}
|
|
227
|
-
onClick={() => { setDraft(
|
|
281
|
+
onClick={() => { setDraft(draftFromValue(value)); setStatus(IDLE_STATUS) }}>
|
|
228
282
|
{t('discard')}
|
|
229
283
|
</button>
|
|
230
284
|
<button type="button" className="dne-btn dne-btnPrimary" disabled={!dirty || invalid || saving || disabled}
|
package/src/client/client.ts
CHANGED
|
@@ -3,21 +3,29 @@
|
|
|
3
3
|
* 构建产物为 window.__ModuleLoader__.load({id, factory}) 形式的 CJS factory
|
|
4
4
|
* (包装见 tsdown.config.ts);样式沿用官方 data-plugin-css 约定,HMR 据此卸载。
|
|
5
5
|
*
|
|
6
|
-
* 类型说明:浏览器半只用到 slots/locale
|
|
7
|
-
*
|
|
8
|
-
* dsh-client-ui-settings-plugins 的
|
|
6
|
+
* 类型说明:浏览器半只用到 slots/locale/settingsScope/connection 的很窄一面,
|
|
7
|
+
* 此处以最小本地接口声明(见 ./scope.ts),避免为构建期类型引入整条官方
|
|
8
|
+
* client 依赖树;运行时契约以官方 dsh-client-ui-settings-plugins 的
|
|
9
|
+
* settings.plugin.item 插槽与 dsh-client-ui-settings 的 settingsScope 服务为准。
|
|
10
|
+
* rc7 起该插槽为 keyed 槽位:key 必须是卡片编辑的 settings 命名空间
|
|
11
|
+
* (即服务端 settingsNamespace() 注册的同一字面量,见 ../ns.ts),
|
|
12
|
+
* 官方配置页按此 key 与 Host 已注册命名空间配对分发。
|
|
13
|
+
* 配置读写经官方 settingsScope 传输(rc7 起第三方命名空间对 settings RPC
|
|
14
|
+
* 全量开放);自定义端点仅剩「发送测试邮件」(api.ts)。
|
|
9
15
|
* @module @dsh-plus/notify-email/client
|
|
10
16
|
*/
|
|
11
17
|
import type { Context } from '@deepseek-ai/cordis'
|
|
12
18
|
|
|
19
|
+
import { SETTINGS_NS } from '../ns.ts'
|
|
13
20
|
import { NotifyEmailCard } from './card.tsx'
|
|
14
21
|
import { en, NS, zh } from './i18n.ts'
|
|
22
|
+
import type { Scope, SettingsApi } from './scope.ts'
|
|
15
23
|
import { injectStyle } from './styles.ts'
|
|
16
24
|
|
|
17
25
|
export const name = 'dsh-plus-notify-email'
|
|
18
26
|
|
|
19
27
|
/** 浏览器半需要的 cordis 服务 key(loader 据此注入;package.json 的 dsh.client.inject 管包加载顺序)。 */
|
|
20
|
-
export const inject = ['slots', 'locale'] as const
|
|
28
|
+
export const inject = ['slots', 'locale', 'settingsScope', 'connection'] as const
|
|
21
29
|
|
|
22
30
|
interface SlotsLike {
|
|
23
31
|
inject(key: string, callback: () => unknown): unknown
|
|
@@ -29,9 +37,19 @@ interface LocaleLike {
|
|
|
29
37
|
bind(ns: string): (key: string) => string
|
|
30
38
|
}
|
|
31
39
|
|
|
40
|
+
interface SettingsScopeServiceLike {
|
|
41
|
+
bind(spec: { namespace: string }): Scope
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
interface ConnectionLike {
|
|
45
|
+
settings: SettingsApi
|
|
46
|
+
}
|
|
47
|
+
|
|
32
48
|
interface ClientContext {
|
|
33
49
|
slots: SlotsLike
|
|
34
50
|
locale: LocaleLike
|
|
51
|
+
settingsScope: SettingsScopeServiceLike
|
|
52
|
+
get(key: 'connection'): ConnectionLike
|
|
35
53
|
effect(execute: () => () => void, label?: string): unknown
|
|
36
54
|
}
|
|
37
55
|
|
|
@@ -48,14 +66,15 @@ export function apply(ctx: Context): void {
|
|
|
48
66
|
() => c.locale.register(NS, { zh, en }),
|
|
49
67
|
'notify-email: locale',
|
|
50
68
|
)
|
|
69
|
+
const scope = c.settingsScope.bind({ namespace: SETTINGS_NS })
|
|
70
|
+
const api = c.get('connection').api.settings
|
|
51
71
|
c.slots.inject('settings.plugin.item', () =>
|
|
52
72
|
c.slots.register(
|
|
53
73
|
{
|
|
54
74
|
name: 'settings.plugin.item',
|
|
55
|
-
|
|
56
|
-
order: 100,
|
|
75
|
+
key: SETTINGS_NS,
|
|
57
76
|
locale: NS,
|
|
58
|
-
inject: () => ({ t: c.locale.bind(NS) }),
|
|
77
|
+
inject: () => ({ t: c.locale.bind(NS), scope, api }),
|
|
59
78
|
},
|
|
60
79
|
NotifyEmailCard,
|
|
61
80
|
),
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 浏览器半 settingsScope/connection 的最小本地接口声明。
|
|
3
|
+
* 只声明本插件用到的窄面,避免为构建期类型引入整条官方 client 依赖树;
|
|
4
|
+
* 运行时契约以官方 dsh-client-ui-settings 的 settingsScope 服务与
|
|
5
|
+
* dsh-client-connection 的 settings RPC 面为准(rc7:白名单已移除,
|
|
6
|
+
* 任何已注册命名空间均可 describe/update/replace)。
|
|
7
|
+
* @module notify-email/client/scope
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/** settings 命名空间 scope 的快照(官方 SettingsScopeSnapshot 的最小投影)。 */
|
|
11
|
+
export interface ScopeSnapshot {
|
|
12
|
+
status: 'loading' | 'ready' | 'unavailable'
|
|
13
|
+
/** schema 解析后的配置值(secret 字段已脱敏剥除)。 */
|
|
14
|
+
value: unknown
|
|
15
|
+
/** 命名空间 revision,写操作 fencing 用;首次 Host 应答前为 undefined。 */
|
|
16
|
+
revision: number | undefined
|
|
17
|
+
/** Host 文档是否可写。 */
|
|
18
|
+
writable: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** settingsScope.bind({namespace}) 返回的控制器面。 */
|
|
22
|
+
export interface Scope {
|
|
23
|
+
getSnapshot(): ScopeSnapshot
|
|
24
|
+
subscribe(listener: () => void): () => void
|
|
25
|
+
load(): Promise<void>
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** connection api.settings 的 RPC 面(本插件用到的三个方法)。 */
|
|
29
|
+
export interface SettingsApi {
|
|
30
|
+
describe(): Promise<{ result: { ok: boolean; value?: { namespaces: Array<{ ns: string; secrets: Array<{ path: string[]; set: boolean }> }> }; error?: { message?: string } } }>
|
|
31
|
+
update(request: { ns: string; patch: Record<string, unknown>; expectedRevision?: number }): Promise<{ result: { ok: boolean; error?: { message?: string } } }>
|
|
32
|
+
replace(request: { ns: string; section: Record<string, unknown>; expectedRevision?: number }): Promise<{ result: { ok: boolean; error?: { message?: string } } }>
|
|
33
|
+
}
|
package/src/config-api.ts
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* namespace 一律 settings-not-exposed;故卡片数据走自建 webServer 路由,
|
|
5
|
-
* 读写仍经 ctx.settings 用户层(持久化 $DSH_HOME/settings.yaml)。
|
|
2
|
+
* 自定义端点 HTTP 通道:配置读写已迁移到官方 settings RPC(rc7 起第三方
|
|
3
|
+
* 命名空间全量开放),这里只保留官方传输没有的「发送测试邮件」端点。
|
|
6
4
|
* 仅监听 dsh web 同源(webserver 默认 127.0.0.1),与 GUI 其余面同等暴露面。
|
|
7
5
|
* @module notify-email/config-api
|
|
8
6
|
*/
|
|
@@ -10,78 +8,22 @@ import type { IncomingMessage, ServerResponse } from 'node:http'
|
|
|
10
8
|
|
|
11
9
|
import type { Context } from '@deepseek-ai/cordis'
|
|
12
10
|
|
|
13
|
-
import { SETTINGS_NS, toUserPatch, toWire, WirePatch, type WirePatchInput } from './config.ts'
|
|
14
11
|
import type { NotifyEmailService } from './service.ts'
|
|
15
12
|
|
|
16
|
-
const ROUTE_CONFIG = '/dsh-plus/notify-email/config'
|
|
17
13
|
const ROUTE_TEST = '/dsh-plus/notify-email/test'
|
|
18
|
-
const MAX_BODY_BYTES = 64 * 1024
|
|
19
14
|
|
|
20
15
|
function sendJson(res: ServerResponse, status: number, body: unknown): void {
|
|
21
16
|
res.writeHead(status, { 'content-type': 'application/json; charset=utf-8' })
|
|
22
17
|
res.end(JSON.stringify(body))
|
|
23
18
|
}
|
|
24
19
|
|
|
25
|
-
function readBody(req: IncomingMessage): Promise<string> {
|
|
26
|
-
return new Promise((resolve, reject) => {
|
|
27
|
-
const chunks: Buffer[] = []
|
|
28
|
-
let size = 0
|
|
29
|
-
req.on('data', (chunk: Buffer) => {
|
|
30
|
-
size += chunk.length
|
|
31
|
-
if (size > MAX_BODY_BYTES) {
|
|
32
|
-
reject(new Error('request body too large'))
|
|
33
|
-
req.destroy()
|
|
34
|
-
return
|
|
35
|
-
}
|
|
36
|
-
chunks.push(chunk)
|
|
37
|
-
})
|
|
38
|
-
req.on('end', () => resolve(Buffer.concat(chunks).toString('utf8')))
|
|
39
|
-
req.on('error', reject)
|
|
40
|
-
})
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
async function readPatch(req: IncomingMessage): Promise<WirePatchInput> {
|
|
44
|
-
const raw = await readBody(req)
|
|
45
|
-
let parsed: unknown
|
|
46
|
-
try {
|
|
47
|
-
parsed = JSON.parse(raw)
|
|
48
|
-
} catch {
|
|
49
|
-
throw new Error('request body is not valid JSON')
|
|
50
|
-
}
|
|
51
|
-
return WirePatch(parsed) as WirePatchInput
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
async function handleConfig(
|
|
55
|
-
ctx: Context,
|
|
56
|
-
service: NotifyEmailService,
|
|
57
|
-
req: IncomingMessage,
|
|
58
|
-
res: ServerResponse,
|
|
59
|
-
): Promise<void> {
|
|
60
|
-
const settings = ctx.get('settings')
|
|
61
|
-
if (req.method === 'GET') {
|
|
62
|
-
sendJson(res, 200, toWire(service.currentConfig(), settings !== undefined))
|
|
63
|
-
return
|
|
64
|
-
}
|
|
65
|
-
if (req.method !== 'PUT') {
|
|
66
|
-
sendJson(res, 405, { error: 'method not allowed' })
|
|
67
|
-
return
|
|
68
|
-
}
|
|
69
|
-
if (settings === undefined) {
|
|
70
|
-
sendJson(res, 503, { error: 'settings provider 不可用,无法在线保存;请编辑 settings.yaml' })
|
|
71
|
-
return
|
|
72
|
-
}
|
|
73
|
-
const patch = toUserPatch(await readPatch(req))
|
|
74
|
-
await settings.update(SETTINGS_NS, patch)
|
|
75
|
-
sendJson(res, 200, toWire(service.currentConfig(), true))
|
|
76
|
-
}
|
|
77
|
-
|
|
78
20
|
async function handleTest(service: NotifyEmailService, res: ServerResponse): Promise<void> {
|
|
79
21
|
const result = await service.sendTest()
|
|
80
22
|
sendJson(res, result.ok ? 200 : 502, result)
|
|
81
23
|
}
|
|
82
24
|
|
|
83
|
-
/**
|
|
84
|
-
export function
|
|
25
|
+
/** 注册测试发信路由;返回是否注册成功(webServer 缺失时由调用方保证不调用)。 */
|
|
26
|
+
export function registerTestApi(ctx: Context, service: NotifyEmailService): void {
|
|
85
27
|
const logger = ctx.logger('notify-email')
|
|
86
28
|
const guard = (
|
|
87
29
|
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>,
|
|
@@ -91,17 +33,12 @@ export function registerConfigApi(ctx: Context, service: NotifyEmailService): vo
|
|
|
91
33
|
await handler(req, res)
|
|
92
34
|
} catch (error) {
|
|
93
35
|
const message = error instanceof Error ? error.message : String(error)
|
|
94
|
-
logger.warn(`
|
|
36
|
+
logger.warn(`test api ${req.method ?? '?'} ${req.url ?? '?'} failed: ${message}`)
|
|
95
37
|
if (!res.headersSent) sendJson(res, 400, { error: message })
|
|
96
38
|
else res.end()
|
|
97
39
|
}
|
|
98
40
|
}
|
|
99
41
|
}
|
|
100
|
-
ctx.webServer.register({
|
|
101
|
-
kind: 'exact',
|
|
102
|
-
path: ROUTE_CONFIG,
|
|
103
|
-
handler: guard((req, res) => handleConfig(ctx, service, req, res)),
|
|
104
|
-
})
|
|
105
42
|
ctx.webServer.register({
|
|
106
43
|
kind: 'exact',
|
|
107
44
|
path: ROUTE_TEST,
|
package/src/config.ts
CHANGED
|
@@ -7,8 +7,10 @@
|
|
|
7
7
|
import z from '@deepseek-ai/schemastery'
|
|
8
8
|
import { settingsNamespace } from '@deepseek-ai/dsh-settings'
|
|
9
9
|
|
|
10
|
-
|
|
11
|
-
|
|
10
|
+
import { SETTINGS_NS as NS_LITERAL } from './ns.ts'
|
|
11
|
+
|
|
12
|
+
/** settings 命名空间;webui 配置卡片与插件运行期读取同一份(字面量见 ./ns.ts)。 */
|
|
13
|
+
export const SETTINGS_NS = settingsNamespace(NS_LITERAL)
|
|
12
14
|
|
|
13
15
|
const SmtpSchema = z.object({
|
|
14
16
|
host: z.string().description('SMTP 服务器主机名').default(''),
|
|
@@ -42,90 +44,7 @@ export const Config = z.object({
|
|
|
42
44
|
export type NotifyEmailConfig = Schemastery.TypeT<typeof Config>
|
|
43
45
|
export type TriggerToggles = NotifyEmailConfig['triggers']
|
|
44
46
|
|
|
45
|
-
/** 配置卡片读取用的传输对象:pass 永不回传,只报告是否已配置。 */
|
|
46
|
-
export interface WireConfig {
|
|
47
|
-
enabled: boolean
|
|
48
|
-
smtp: {
|
|
49
|
-
host: string
|
|
50
|
-
port: number
|
|
51
|
-
secure: boolean
|
|
52
|
-
user: string
|
|
53
|
-
from: string
|
|
54
|
-
passConfigured: boolean
|
|
55
|
-
}
|
|
56
|
-
to: string[]
|
|
57
|
-
triggers: TriggerToggles
|
|
58
|
-
idleDebounceMs: number
|
|
59
|
-
maxBodyChars: number
|
|
60
|
-
dryRun: boolean
|
|
61
|
-
/** 是否存在可写的 settings provider(决定卡片是否允许编辑)。 */
|
|
62
|
-
writable: boolean
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
export function toWire(cfg: NotifyEmailConfig, writable: boolean): WireConfig {
|
|
66
|
-
return {
|
|
67
|
-
enabled: cfg.enabled,
|
|
68
|
-
smtp: {
|
|
69
|
-
host: cfg.smtp.host,
|
|
70
|
-
port: cfg.smtp.port,
|
|
71
|
-
secure: cfg.smtp.secure,
|
|
72
|
-
user: cfg.smtp.user,
|
|
73
|
-
from: cfg.smtp.from,
|
|
74
|
-
passConfigured: cfg.smtp.pass.length > 0,
|
|
75
|
-
},
|
|
76
|
-
to: [...cfg.to],
|
|
77
|
-
triggers: { ...cfg.triggers },
|
|
78
|
-
idleDebounceMs: cfg.idleDebounceMs,
|
|
79
|
-
maxBodyChars: cfg.maxBodyChars,
|
|
80
|
-
dryRun: cfg.dryRun,
|
|
81
|
-
writable,
|
|
82
|
-
}
|
|
83
|
-
}
|
|
84
|
-
|
|
85
47
|
/** 是否具备发信条件(enabled 之外的最小完整性)。 */
|
|
86
48
|
export function isDeliverable(cfg: NotifyEmailConfig): boolean {
|
|
87
49
|
return cfg.enabled && cfg.smtp.host.length > 0 && cfg.smtp.from.length > 0 && cfg.to.length > 0
|
|
88
50
|
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* 配置卡片写回用的 patch schema:字段全部可选(无默认值),
|
|
92
|
-
* 只携带用户真实编辑过的字段,交给 settings.update 深合并进用户层。
|
|
93
|
-
*/
|
|
94
|
-
export const WirePatch = z.object({
|
|
95
|
-
enabled: z.boolean(),
|
|
96
|
-
smtp: z.object({
|
|
97
|
-
host: z.string(),
|
|
98
|
-
port: z.natural().max(65535),
|
|
99
|
-
secure: z.boolean(),
|
|
100
|
-
user: z.string(),
|
|
101
|
-
pass: z.string(),
|
|
102
|
-
from: z.string(),
|
|
103
|
-
}),
|
|
104
|
-
to: z.array(z.string()),
|
|
105
|
-
triggers: z.object({
|
|
106
|
-
onComplete: z.boolean(),
|
|
107
|
-
onError: z.boolean(),
|
|
108
|
-
onAborted: z.boolean(),
|
|
109
|
-
onQuestion: z.boolean(),
|
|
110
|
-
onPlanReview: z.boolean(),
|
|
111
|
-
}),
|
|
112
|
-
idleDebounceMs: z.natural(),
|
|
113
|
-
maxBodyChars: z.natural().min(200),
|
|
114
|
-
dryRun: z.boolean(),
|
|
115
|
-
})
|
|
116
|
-
|
|
117
|
-
export type WirePatchInput = Schemastery.TypeS<typeof WirePatch>
|
|
118
|
-
|
|
119
|
-
/**
|
|
120
|
-
* 把卡片提交的 patch 规整为 settings.update 的用户层 patch。
|
|
121
|
-
* smtp.pass 为空串表示「保持不变」,从 patch 中剔除,避免覆盖已存密码。
|
|
122
|
-
*/
|
|
123
|
-
export function toUserPatch(input: WirePatchInput): Record<string, unknown> {
|
|
124
|
-
const patch: Record<string, unknown> = { ...input }
|
|
125
|
-
if (input.smtp !== undefined && input.smtp !== null) {
|
|
126
|
-
const smtp: Record<string, unknown> = { ...input.smtp }
|
|
127
|
-
if (smtp['pass'] === '') delete smtp['pass']
|
|
128
|
-
patch['smtp'] = smtp
|
|
129
|
-
}
|
|
130
|
-
return patch
|
|
131
|
-
}
|
package/src/ns.ts
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* settings 命名空间字面量(纯常量,零依赖,浏览器半可安全引入)。
|
|
3
|
+
* 服务端经 settingsNamespace() 品牌化后使用(config.ts),浏览器半将其作为
|
|
4
|
+
* settings.plugin.item keyed 槽位的 key —— 同一字面量两处共用,防止漂移。
|
|
5
|
+
* 契约:dsh rc7 起该槽位按「卡片编辑的 settings 命名空间」作 key 分发,
|
|
6
|
+
* 官方配置页只渲染 key 命中 Host 已注册命名空间的卡片。
|
|
7
|
+
* @module notify-email/ns
|
|
8
|
+
*/
|
|
9
|
+
export const SETTINGS_NS = 'dsh-plus-notify-email'
|
package/src/service.ts
CHANGED
|
@@ -13,7 +13,7 @@ import { createJsonlAuditSink } from './audit.ts'
|
|
|
13
13
|
import { Config, SETTINGS_NS, type NotifyEmailConfig } from './config.ts'
|
|
14
14
|
import { Mailer } from './mailer.ts'
|
|
15
15
|
import { installSettingsSection } from '@deepseek-ai/dsh-settings'
|
|
16
|
-
import {
|
|
16
|
+
import { registerTestApi } from './config-api.ts'
|
|
17
17
|
import { createDecisionTrigger, createTurnEndTrigger } from './triggers/builtin.ts'
|
|
18
18
|
import type { DecisionCall, NotifyTrigger, TurnEndInfo } from './triggers/types.ts'
|
|
19
19
|
import { installDecisionWatcher } from './watchers/decision.ts'
|
|
@@ -52,7 +52,7 @@ export class NotifyEmailService extends Service {
|
|
|
52
52
|
installDecisionWatcher(ctx, this)
|
|
53
53
|
installTurnEndWatcher(ctx, this, () => this.current())
|
|
54
54
|
ctx.inject(['webServer'], (webCtx) => {
|
|
55
|
-
|
|
55
|
+
registerTestApi(webCtx, this)
|
|
56
56
|
})
|
|
57
57
|
}
|
|
58
58
|
|