@dsh-plus/notify-email 0.1.1 → 0.1.3
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 +130 -51
- package/lib/index.js +4 -114
- package/package.json +4 -2
- package/src/client/api.ts +2 -63
- package/src/client/card.tsx +100 -46
- package/src/client/client.ts +84 -6
- package/src/client/scope.ts +37 -0
- package/src/config-api.ts +5 -68
- package/src/config.ts +0 -83
- package/src/service.ts +2 -2
package/lib/client.js
CHANGED
|
@@ -45,24 +45,12 @@ const SETTINGS_NS = "dsh-plus-notify-email";
|
|
|
45
45
|
|
|
46
46
|
//#endregion
|
|
47
47
|
//#region src/client/api.ts
|
|
48
|
-
const ROUTE_CONFIG = "/dsh-plus/notify-email/config";
|
|
49
48
|
const ROUTE_TEST = "/dsh-plus/notify-email/test";
|
|
50
49
|
async function parse(res) {
|
|
51
50
|
const body = await res.json();
|
|
52
51
|
if (!res.ok) throw new Error(body.error ?? `HTTP ${res.status}`);
|
|
53
52
|
return body;
|
|
54
53
|
}
|
|
55
|
-
async function fetchConfig() {
|
|
56
|
-
return parse(await fetch(ROUTE_CONFIG, { credentials: "same-origin" }));
|
|
57
|
-
}
|
|
58
|
-
async function saveConfig(patch) {
|
|
59
|
-
return parse(await fetch(ROUTE_CONFIG, {
|
|
60
|
-
method: "PUT",
|
|
61
|
-
credentials: "same-origin",
|
|
62
|
-
headers: { "content-type": "application/json" },
|
|
63
|
-
body: JSON.stringify(patch)
|
|
64
|
-
}));
|
|
65
|
-
}
|
|
66
54
|
async function sendTest() {
|
|
67
55
|
return parse(await fetch(ROUTE_TEST, {
|
|
68
56
|
method: "POST",
|
|
@@ -128,20 +116,20 @@ const IDLE_STATUS = {
|
|
|
128
116
|
kind: "idle",
|
|
129
117
|
text: ""
|
|
130
118
|
};
|
|
131
|
-
function
|
|
119
|
+
function draftFromValue(value) {
|
|
132
120
|
return {
|
|
133
|
-
enabled:
|
|
134
|
-
host:
|
|
135
|
-
port: String(
|
|
136
|
-
secure:
|
|
137
|
-
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,
|
|
138
126
|
pass: "",
|
|
139
|
-
from:
|
|
140
|
-
toText:
|
|
141
|
-
triggers: { ...
|
|
142
|
-
idleDebounceMs: String(
|
|
143
|
-
maxBodyChars: String(
|
|
144
|
-
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
|
|
145
133
|
};
|
|
146
134
|
}
|
|
147
135
|
function isPositiveInt(text) {
|
|
@@ -173,41 +161,43 @@ const TRIGGER_KEYS = [
|
|
|
173
161
|
"onPlanReview"
|
|
174
162
|
];
|
|
175
163
|
function NotifyEmailCard(props) {
|
|
176
|
-
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;
|
|
177
167
|
const [open, setOpen] = (0, react.useState)(false);
|
|
178
|
-
const [wire, setWire] = (0, react.useState)(null);
|
|
179
168
|
const [draft, setDraft] = (0, react.useState)(null);
|
|
180
|
-
const [
|
|
169
|
+
const [passConfigured, setPassConfigured] = (0, react.useState)(false);
|
|
181
170
|
const [saving, setSaving] = (0, react.useState)(false);
|
|
182
171
|
const [testing, setTesting] = (0, react.useState)(false);
|
|
183
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]);
|
|
184
177
|
(0, react.useEffect)(() => {
|
|
185
178
|
let alive = true;
|
|
186
|
-
|
|
187
|
-
if (!alive) return;
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
}).catch(() => {
|
|
191
|
-
if (alive) setFailed(true);
|
|
192
|
-
});
|
|
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(() => {});
|
|
193
184
|
return () => {
|
|
194
185
|
alive = false;
|
|
195
186
|
};
|
|
196
|
-
}, []);
|
|
197
|
-
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]);
|
|
198
189
|
const invalid = (0, react.useMemo)(() => draft !== null && (!isPositiveInt(draft.port) || !isPositiveInt(draft.idleDebounceMs) || !isPositiveInt(draft.maxBodyChars) || Number(draft.maxBodyChars) < 200), [draft]);
|
|
199
|
-
if (
|
|
200
|
-
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", {
|
|
201
191
|
className: "dne-card",
|
|
202
192
|
children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", {
|
|
203
193
|
className: "dne-readOnly",
|
|
204
194
|
children: t("loading")
|
|
205
195
|
})
|
|
206
196
|
});
|
|
207
|
-
const edit = (key,
|
|
197
|
+
const edit = (key, editValue) => {
|
|
208
198
|
setDraft({
|
|
209
199
|
...draft,
|
|
210
|
-
[key]:
|
|
200
|
+
[key]: editValue
|
|
211
201
|
});
|
|
212
202
|
setStatus(IDLE_STATUS);
|
|
213
203
|
};
|
|
@@ -219,14 +209,24 @@ function NotifyEmailCard(props) {
|
|
|
219
209
|
};
|
|
220
210
|
const onSave = () => {
|
|
221
211
|
setSaving(true);
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
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));
|
|
225
224
|
setStatus(IDLE_STATUS);
|
|
226
225
|
}).catch((error) => {
|
|
226
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
227
227
|
setStatus({
|
|
228
228
|
kind: "error",
|
|
229
|
-
text: `${t("saveFailed")}
|
|
229
|
+
text: `${t("saveFailed")}${message}`
|
|
230
230
|
});
|
|
231
231
|
}).finally(() => setSaving(false));
|
|
232
232
|
};
|
|
@@ -251,7 +251,7 @@ function NotifyEmailCard(props) {
|
|
|
251
251
|
});
|
|
252
252
|
}).finally(() => setTesting(false));
|
|
253
253
|
};
|
|
254
|
-
const disabled = !
|
|
254
|
+
const disabled = !snapshot.writable;
|
|
255
255
|
return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("li", {
|
|
256
256
|
className: `dne-card${open ? " dne-cardOpen" : ""}`,
|
|
257
257
|
children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
|
|
@@ -337,8 +337,8 @@ function NotifyEmailCard(props) {
|
|
|
337
337
|
password: true,
|
|
338
338
|
disabled,
|
|
339
339
|
badge: {
|
|
340
|
-
text:
|
|
341
|
-
set:
|
|
340
|
+
text: passConfigured ? t("passSet") : t("passUnset"),
|
|
341
|
+
set: passConfigured
|
|
342
342
|
},
|
|
343
343
|
onEdit: (v) => edit("pass", v)
|
|
344
344
|
}),
|
|
@@ -418,7 +418,7 @@ function NotifyEmailCard(props) {
|
|
|
418
418
|
className: "dne-btn dne-btnGhost",
|
|
419
419
|
disabled: !dirty || saving,
|
|
420
420
|
onClick: () => {
|
|
421
|
-
setDraft(
|
|
421
|
+
setDraft(draftFromValue(value));
|
|
422
422
|
setStatus(IDLE_STATUS);
|
|
423
423
|
},
|
|
424
424
|
children: t("discard")
|
|
@@ -607,7 +607,81 @@ function injectStyle() {
|
|
|
607
607
|
//#region src/client/client.ts
|
|
608
608
|
const name = "dsh-plus-notify-email";
|
|
609
609
|
/** 浏览器半需要的 cordis 服务 key(loader 据此注入;package.json 的 dsh.client.inject 管包加载顺序)。 */
|
|
610
|
-
const inject = [
|
|
610
|
+
const inject = [
|
|
611
|
+
"slots",
|
|
612
|
+
"locale",
|
|
613
|
+
"connection",
|
|
614
|
+
"remote"
|
|
615
|
+
];
|
|
616
|
+
/**
|
|
617
|
+
* api 直连实现的命名空间 scope(官方配置页 tab 同款模式)。
|
|
618
|
+
* 读:describe 取命名空间视图(脱敏 value/revision + 顶层 writable);
|
|
619
|
+
* 刷新:remote `settings/document-updated`(按命名空间过滤)与
|
|
620
|
+
* `connection/reset`;generation 防旧读覆盖新发布。任何页面 origin
|
|
621
|
+
* (loopback 或 tailnet 信任域名)下行为一致。
|
|
622
|
+
*/
|
|
623
|
+
function createApiScope(api, ns, c) {
|
|
624
|
+
let state = {
|
|
625
|
+
status: "loading",
|
|
626
|
+
value: void 0,
|
|
627
|
+
revision: void 0,
|
|
628
|
+
writable: false
|
|
629
|
+
};
|
|
630
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
631
|
+
let generation = 0;
|
|
632
|
+
const publish = (next) => {
|
|
633
|
+
state = next;
|
|
634
|
+
for (const listener of [...listeners]) listener();
|
|
635
|
+
};
|
|
636
|
+
const load = async () => {
|
|
637
|
+
const gen = ++generation;
|
|
638
|
+
let response;
|
|
639
|
+
try {
|
|
640
|
+
response = await api.describe({});
|
|
641
|
+
} catch {
|
|
642
|
+
return;
|
|
643
|
+
}
|
|
644
|
+
if (gen !== generation || !response.result.ok) return;
|
|
645
|
+
const writable = response.result.value?.writable ?? false;
|
|
646
|
+
const view = response.result.value?.namespaces.find((candidate) => candidate.ns === ns);
|
|
647
|
+
if (view === void 0) {
|
|
648
|
+
publish({
|
|
649
|
+
status: "unavailable",
|
|
650
|
+
value: void 0,
|
|
651
|
+
revision: void 0,
|
|
652
|
+
writable
|
|
653
|
+
});
|
|
654
|
+
return;
|
|
655
|
+
}
|
|
656
|
+
publish({
|
|
657
|
+
status: "ready",
|
|
658
|
+
value: view.value,
|
|
659
|
+
revision: view.revision,
|
|
660
|
+
writable
|
|
661
|
+
});
|
|
662
|
+
};
|
|
663
|
+
const refresh = (namespace) => {
|
|
664
|
+
if (namespace !== void 0 && namespace !== ns) return;
|
|
665
|
+
load();
|
|
666
|
+
};
|
|
667
|
+
const disposers = [c.get("remote").$on("settings/document-updated", refresh), c.on("connection/reset", () => {
|
|
668
|
+
load();
|
|
669
|
+
})];
|
|
670
|
+
c.effect(() => () => {
|
|
671
|
+
for (const dispose of disposers) dispose();
|
|
672
|
+
}, "notify-email: settings scope");
|
|
673
|
+
load();
|
|
674
|
+
return {
|
|
675
|
+
getSnapshot: () => state,
|
|
676
|
+
subscribe: (listener) => {
|
|
677
|
+
listeners.add(listener);
|
|
678
|
+
return () => {
|
|
679
|
+
listeners.delete(listener);
|
|
680
|
+
};
|
|
681
|
+
},
|
|
682
|
+
load
|
|
683
|
+
};
|
|
684
|
+
}
|
|
611
685
|
function apply(ctx) {
|
|
612
686
|
const c = ctx;
|
|
613
687
|
const tag = injectStyle();
|
|
@@ -618,12 +692,17 @@ function apply(ctx) {
|
|
|
618
692
|
zh,
|
|
619
693
|
en
|
|
620
694
|
}), "notify-email: locale");
|
|
695
|
+
const api = c.get("connection").api.settings;
|
|
696
|
+
const scope = createApiScope(api, SETTINGS_NS, c);
|
|
621
697
|
c.slots.inject("settings.plugin.item", () => c.slots.register({
|
|
622
698
|
name: "settings.plugin.item",
|
|
623
699
|
key: SETTINGS_NS,
|
|
624
|
-
order: 100,
|
|
625
700
|
locale: NS,
|
|
626
|
-
inject: () => ({
|
|
701
|
+
inject: () => ({
|
|
702
|
+
t: c.locale.bind(NS),
|
|
703
|
+
scope,
|
|
704
|
+
api
|
|
705
|
+
})
|
|
627
706
|
}, NotifyEmailCard));
|
|
628
707
|
}
|
|
629
708
|
|
package/lib/index.js
CHANGED
|
@@ -58,64 +58,6 @@ const Config = z.object({
|
|
|
58
58
|
maxBodyChars: z.natural().min(200).description("邮件正文内容截断长度").default(4e3),
|
|
59
59
|
dryRun: z.boolean().description("仅记录日志不真实发送(开发调试用)").default(false)
|
|
60
60
|
});
|
|
61
|
-
function toWire(cfg, writable) {
|
|
62
|
-
return {
|
|
63
|
-
enabled: cfg.enabled,
|
|
64
|
-
smtp: {
|
|
65
|
-
host: cfg.smtp.host,
|
|
66
|
-
port: cfg.smtp.port,
|
|
67
|
-
secure: cfg.smtp.secure,
|
|
68
|
-
user: cfg.smtp.user,
|
|
69
|
-
from: cfg.smtp.from,
|
|
70
|
-
passConfigured: cfg.smtp.pass.length > 0
|
|
71
|
-
},
|
|
72
|
-
to: [...cfg.to],
|
|
73
|
-
triggers: { ...cfg.triggers },
|
|
74
|
-
idleDebounceMs: cfg.idleDebounceMs,
|
|
75
|
-
maxBodyChars: cfg.maxBodyChars,
|
|
76
|
-
dryRun: cfg.dryRun,
|
|
77
|
-
writable
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
/**
|
|
81
|
-
* 配置卡片写回用的 patch schema:字段全部可选(无默认值),
|
|
82
|
-
* 只携带用户真实编辑过的字段,交给 settings.update 深合并进用户层。
|
|
83
|
-
*/
|
|
84
|
-
const WirePatch = z.object({
|
|
85
|
-
enabled: z.boolean(),
|
|
86
|
-
smtp: z.object({
|
|
87
|
-
host: z.string(),
|
|
88
|
-
port: z.natural().max(65535),
|
|
89
|
-
secure: z.boolean(),
|
|
90
|
-
user: z.string(),
|
|
91
|
-
pass: z.string(),
|
|
92
|
-
from: z.string()
|
|
93
|
-
}),
|
|
94
|
-
to: z.array(z.string()),
|
|
95
|
-
triggers: z.object({
|
|
96
|
-
onComplete: z.boolean(),
|
|
97
|
-
onError: z.boolean(),
|
|
98
|
-
onAborted: z.boolean(),
|
|
99
|
-
onQuestion: z.boolean(),
|
|
100
|
-
onPlanReview: z.boolean()
|
|
101
|
-
}),
|
|
102
|
-
idleDebounceMs: z.natural(),
|
|
103
|
-
maxBodyChars: z.natural().min(200),
|
|
104
|
-
dryRun: z.boolean()
|
|
105
|
-
});
|
|
106
|
-
/**
|
|
107
|
-
* 把卡片提交的 patch 规整为 settings.update 的用户层 patch。
|
|
108
|
-
* smtp.pass 为空串表示「保持不变」,从 patch 中剔除,避免覆盖已存密码。
|
|
109
|
-
*/
|
|
110
|
-
function toUserPatch(input) {
|
|
111
|
-
const patch = { ...input };
|
|
112
|
-
if (input.smtp !== void 0 && input.smtp !== null) {
|
|
113
|
-
const smtp = { ...input.smtp };
|
|
114
|
-
if (smtp["pass"] === "") delete smtp["pass"];
|
|
115
|
-
patch["smtp"] = smtp;
|
|
116
|
-
}
|
|
117
|
-
return patch;
|
|
118
|
-
}
|
|
119
61
|
|
|
120
62
|
//#endregion
|
|
121
63
|
//#region src/audit.ts
|
|
@@ -221,64 +163,17 @@ var Mailer = class {
|
|
|
221
163
|
|
|
222
164
|
//#endregion
|
|
223
165
|
//#region src/config-api.ts
|
|
224
|
-
const ROUTE_CONFIG = "/dsh-plus/notify-email/config";
|
|
225
166
|
const ROUTE_TEST = "/dsh-plus/notify-email/test";
|
|
226
|
-
const MAX_BODY_BYTES = 64 * 1024;
|
|
227
167
|
function sendJson(res, status, body) {
|
|
228
168
|
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
229
169
|
res.end(JSON.stringify(body));
|
|
230
170
|
}
|
|
231
|
-
function readBody(req) {
|
|
232
|
-
return new Promise((resolve, reject) => {
|
|
233
|
-
const chunks = [];
|
|
234
|
-
let size = 0;
|
|
235
|
-
req.on("data", (chunk) => {
|
|
236
|
-
size += chunk.length;
|
|
237
|
-
if (size > MAX_BODY_BYTES) {
|
|
238
|
-
reject(/* @__PURE__ */ new Error("request body too large"));
|
|
239
|
-
req.destroy();
|
|
240
|
-
return;
|
|
241
|
-
}
|
|
242
|
-
chunks.push(chunk);
|
|
243
|
-
});
|
|
244
|
-
req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
|
|
245
|
-
req.on("error", reject);
|
|
246
|
-
});
|
|
247
|
-
}
|
|
248
|
-
async function readPatch(req) {
|
|
249
|
-
const raw = await readBody(req);
|
|
250
|
-
let parsed;
|
|
251
|
-
try {
|
|
252
|
-
parsed = JSON.parse(raw);
|
|
253
|
-
} catch {
|
|
254
|
-
throw new Error("request body is not valid JSON");
|
|
255
|
-
}
|
|
256
|
-
return WirePatch(parsed);
|
|
257
|
-
}
|
|
258
|
-
async function handleConfig(ctx, service, req, res) {
|
|
259
|
-
const settings = ctx.get("settings");
|
|
260
|
-
if (req.method === "GET") {
|
|
261
|
-
sendJson(res, 200, toWire(service.currentConfig(), settings !== void 0));
|
|
262
|
-
return;
|
|
263
|
-
}
|
|
264
|
-
if (req.method !== "PUT") {
|
|
265
|
-
sendJson(res, 405, { error: "method not allowed" });
|
|
266
|
-
return;
|
|
267
|
-
}
|
|
268
|
-
if (settings === void 0) {
|
|
269
|
-
sendJson(res, 503, { error: "settings provider 不可用,无法在线保存;请编辑 settings.yaml" });
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
|
-
const patch = toUserPatch(await readPatch(req));
|
|
273
|
-
await settings.update(SETTINGS_NS, patch);
|
|
274
|
-
sendJson(res, 200, toWire(service.currentConfig(), true));
|
|
275
|
-
}
|
|
276
171
|
async function handleTest(service, res) {
|
|
277
172
|
const result = await service.sendTest();
|
|
278
173
|
sendJson(res, result.ok ? 200 : 502, result);
|
|
279
174
|
}
|
|
280
|
-
/**
|
|
281
|
-
function
|
|
175
|
+
/** 注册测试发信路由;返回是否注册成功(webServer 缺失时由调用方保证不调用)。 */
|
|
176
|
+
function registerTestApi(ctx, service) {
|
|
282
177
|
const logger = ctx.logger("notify-email");
|
|
283
178
|
const guard = (handler) => {
|
|
284
179
|
return async (req, res) => {
|
|
@@ -286,17 +181,12 @@ function registerConfigApi(ctx, service) {
|
|
|
286
181
|
await handler(req, res);
|
|
287
182
|
} catch (error) {
|
|
288
183
|
const message = error instanceof Error ? error.message : String(error);
|
|
289
|
-
logger.warn(`
|
|
184
|
+
logger.warn(`test api ${req.method ?? "?"} ${req.url ?? "?"} failed: ${message}`);
|
|
290
185
|
if (!res.headersSent) sendJson(res, 400, { error: message });
|
|
291
186
|
else res.end();
|
|
292
187
|
}
|
|
293
188
|
};
|
|
294
189
|
};
|
|
295
|
-
ctx.webServer.register({
|
|
296
|
-
kind: "exact",
|
|
297
|
-
path: ROUTE_CONFIG,
|
|
298
|
-
handler: guard((req, res) => handleConfig(ctx, service, req, res))
|
|
299
|
-
});
|
|
300
190
|
ctx.webServer.register({
|
|
301
191
|
kind: "exact",
|
|
302
192
|
path: ROUTE_TEST,
|
|
@@ -531,7 +421,7 @@ var NotifyEmailService = class extends Service {
|
|
|
531
421
|
installDecisionWatcher(ctx, this);
|
|
532
422
|
installTurnEndWatcher(ctx, this, () => this.current());
|
|
533
423
|
ctx.inject(["webServer"], (webCtx) => {
|
|
534
|
-
|
|
424
|
+
registerTestApi(webCtx, this);
|
|
535
425
|
});
|
|
536
426
|
}
|
|
537
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.3",
|
|
4
4
|
"description": "dsh-plus service+ui plugin: 任务结束/等待决策/出错时向指定邮箱发送邮件通知",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -24,7 +24,9 @@
|
|
|
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-api-remotes"
|
|
28
30
|
],
|
|
29
31
|
"platform": "web"
|
|
30
32
|
}
|
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,12 +3,16 @@
|
|
|
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/connection/remote 的很窄一面,
|
|
7
|
+
* 此处以最小本地接口声明(见 ./scope.ts),避免为构建期类型引入整条官方
|
|
8
|
+
* client 依赖树;运行时契约以官方 dsh-client-ui-settings-plugins 的
|
|
9
|
+
* settings.plugin.item 插槽与 dsh-client-connection 的 settings RPC 面为准。
|
|
9
10
|
* rc7 起该插槽为 keyed 槽位:key 必须是卡片编辑的 settings 命名空间
|
|
10
11
|
* (即服务端 settingsNamespace() 注册的同一字面量,见 ../ns.ts),
|
|
11
12
|
* 官方配置页按此 key 与 Host 已注册命名空间配对分发。
|
|
13
|
+
* 配置读写经官方 settings RPC 直连(rc7 起第三方命名空间全量开放;
|
|
14
|
+
* 不复用 settingsScope 服务——非 loopback 页面下它无数据,见 scope.ts);
|
|
15
|
+
* 自定义端点仅剩「发送测试邮件」(api.ts)。
|
|
12
16
|
* @module @dsh-plus/notify-email/client
|
|
13
17
|
*/
|
|
14
18
|
import type { Context } from '@deepseek-ai/cordis'
|
|
@@ -16,12 +20,13 @@ import type { Context } from '@deepseek-ai/cordis'
|
|
|
16
20
|
import { SETTINGS_NS } from '../ns.ts'
|
|
17
21
|
import { NotifyEmailCard } from './card.tsx'
|
|
18
22
|
import { en, NS, zh } from './i18n.ts'
|
|
23
|
+
import type { Scope, ScopeSnapshot, SettingsApi } from './scope.ts'
|
|
19
24
|
import { injectStyle } from './styles.ts'
|
|
20
25
|
|
|
21
26
|
export const name = 'dsh-plus-notify-email'
|
|
22
27
|
|
|
23
28
|
/** 浏览器半需要的 cordis 服务 key(loader 据此注入;package.json 的 dsh.client.inject 管包加载顺序)。 */
|
|
24
|
-
export const inject = ['slots', 'locale'] as const
|
|
29
|
+
export const inject = ['slots', 'locale', 'connection', 'remote'] as const
|
|
25
30
|
|
|
26
31
|
interface SlotsLike {
|
|
27
32
|
inject(key: string, callback: () => unknown): unknown
|
|
@@ -33,12 +38,84 @@ interface LocaleLike {
|
|
|
33
38
|
bind(ns: string): (key: string) => string
|
|
34
39
|
}
|
|
35
40
|
|
|
41
|
+
interface RemoteLike {
|
|
42
|
+
$on(event: string, listener: (payload?: unknown) => void): () => void
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
interface ConnectionLike {
|
|
46
|
+
api: { settings: SettingsApi }
|
|
47
|
+
}
|
|
48
|
+
|
|
36
49
|
interface ClientContext {
|
|
37
50
|
slots: SlotsLike
|
|
38
51
|
locale: LocaleLike
|
|
52
|
+
get(key: 'connection'): ConnectionLike
|
|
53
|
+
get(key: 'remote'): RemoteLike
|
|
54
|
+
on(event: string, listener: () => void): () => void
|
|
39
55
|
effect(execute: () => () => void, label?: string): unknown
|
|
40
56
|
}
|
|
41
57
|
|
|
58
|
+
/**
|
|
59
|
+
* api 直连实现的命名空间 scope(官方配置页 tab 同款模式)。
|
|
60
|
+
* 读:describe 取命名空间视图(脱敏 value/revision + 顶层 writable);
|
|
61
|
+
* 刷新:remote `settings/document-updated`(按命名空间过滤)与
|
|
62
|
+
* `connection/reset`;generation 防旧读覆盖新发布。任何页面 origin
|
|
63
|
+
* (loopback 或 tailnet 信任域名)下行为一致。
|
|
64
|
+
*/
|
|
65
|
+
function createApiScope(api: SettingsApi, ns: string, c: ClientContext): Scope {
|
|
66
|
+
let state: ScopeSnapshot = { status: 'loading', value: undefined, revision: undefined, writable: false }
|
|
67
|
+
const listeners = new Set<() => void>()
|
|
68
|
+
let generation = 0
|
|
69
|
+
const publish = (next: ScopeSnapshot): void => {
|
|
70
|
+
state = next
|
|
71
|
+
for (const listener of [...listeners]) listener()
|
|
72
|
+
}
|
|
73
|
+
const load = async (): Promise<void> => {
|
|
74
|
+
const gen = ++generation
|
|
75
|
+
let response
|
|
76
|
+
try {
|
|
77
|
+
response = await api.describe({})
|
|
78
|
+
} catch {
|
|
79
|
+
return
|
|
80
|
+
}
|
|
81
|
+
if (gen !== generation || !response.result.ok) return
|
|
82
|
+
const writable = response.result.value?.writable ?? false
|
|
83
|
+
const view = response.result.value?.namespaces.find((candidate) => candidate.ns === ns)
|
|
84
|
+
if (view === undefined) {
|
|
85
|
+
publish({ status: 'unavailable', value: undefined, revision: undefined, writable })
|
|
86
|
+
return
|
|
87
|
+
}
|
|
88
|
+
publish({ status: 'ready', value: view.value, revision: view.revision, writable })
|
|
89
|
+
}
|
|
90
|
+
const refresh = (namespace?: unknown): void => {
|
|
91
|
+
if (namespace !== undefined && namespace !== ns) return
|
|
92
|
+
void load()
|
|
93
|
+
}
|
|
94
|
+
const disposers = [
|
|
95
|
+
c.get('remote').$on('settings/document-updated', refresh),
|
|
96
|
+
c.on('connection/reset', () => {
|
|
97
|
+
void load()
|
|
98
|
+
}),
|
|
99
|
+
]
|
|
100
|
+
c.effect(
|
|
101
|
+
() => () => {
|
|
102
|
+
for (const dispose of disposers) dispose()
|
|
103
|
+
},
|
|
104
|
+
'notify-email: settings scope',
|
|
105
|
+
)
|
|
106
|
+
void load()
|
|
107
|
+
return {
|
|
108
|
+
getSnapshot: () => state,
|
|
109
|
+
subscribe: (listener: () => void) => {
|
|
110
|
+
listeners.add(listener)
|
|
111
|
+
return () => {
|
|
112
|
+
listeners.delete(listener)
|
|
113
|
+
}
|
|
114
|
+
},
|
|
115
|
+
load,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
42
119
|
export function apply(ctx: Context): void {
|
|
43
120
|
const c = ctx as unknown as ClientContext
|
|
44
121
|
const tag = injectStyle()
|
|
@@ -52,14 +129,15 @@ export function apply(ctx: Context): void {
|
|
|
52
129
|
() => c.locale.register(NS, { zh, en }),
|
|
53
130
|
'notify-email: locale',
|
|
54
131
|
)
|
|
132
|
+
const api = c.get('connection').api.settings
|
|
133
|
+
const scope = createApiScope(api, SETTINGS_NS, c)
|
|
55
134
|
c.slots.inject('settings.plugin.item', () =>
|
|
56
135
|
c.slots.register(
|
|
57
136
|
{
|
|
58
137
|
name: 'settings.plugin.item',
|
|
59
138
|
key: SETTINGS_NS,
|
|
60
|
-
order: 100,
|
|
61
139
|
locale: NS,
|
|
62
|
-
inject: () => ({ t: c.locale.bind(NS) }),
|
|
140
|
+
inject: () => ({ t: c.locale.bind(NS), scope, api }),
|
|
63
141
|
},
|
|
64
142
|
NotifyEmailCard,
|
|
65
143
|
),
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 浏览器半 settingsScope/connection 的最小本地接口声明。
|
|
3
|
+
* 只声明本插件用到的窄面,避免为构建期类型引入整条官方 client 依赖树;
|
|
4
|
+
* 运行时契约以官方 dsh-client-connection 的 settings RPC 面为准
|
|
5
|
+
* (rc7:白名单已移除,任何已注册命名空间均可 describe/update/replace)。
|
|
6
|
+
*
|
|
7
|
+
* 实现说明:本插件不复用 settingsScope 服务的 bind()——它在非 loopback
|
|
8
|
+
* 页面(如经 tailnet 域名访问)下走 memory 模式、永远无数据;这里按官方
|
|
9
|
+
* 配置页 tab 的同款模式用 `api.settings` 直连实现 Scope(describe 读 +
|
|
10
|
+
* remote `settings/document-updated` / `connection/reset` 刷新,见 client.ts)。
|
|
11
|
+
* @module notify-email/client/scope
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** settings 命名空间 scope 的快照(官方 SettingsScopeSnapshot 的最小投影)。 */
|
|
15
|
+
export interface ScopeSnapshot {
|
|
16
|
+
status: 'loading' | 'ready' | 'unavailable'
|
|
17
|
+
/** schema 解析后的配置值(secret 字段已脱敏剥除)。 */
|
|
18
|
+
value: unknown
|
|
19
|
+
/** 命名空间 revision,写操作 fencing 用;首次 Host 应答前为 undefined。 */
|
|
20
|
+
revision: number | undefined
|
|
21
|
+
/** Host 文档是否可写。 */
|
|
22
|
+
writable: boolean
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** settingsScope.bind({namespace}) 返回的控制器面(本插件由 api 直连实现)。 */
|
|
26
|
+
export interface Scope {
|
|
27
|
+
getSnapshot(): ScopeSnapshot
|
|
28
|
+
subscribe(listener: () => void): () => void
|
|
29
|
+
load(): Promise<void>
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** connection api.settings 的 RPC 面(本插件用到的三个方法)。 */
|
|
33
|
+
export interface SettingsApi {
|
|
34
|
+
describe(payload?: Record<string, never>): Promise<{ result: { ok: boolean; value?: { writable: boolean; namespaces: Array<{ ns: string; value: unknown; revision: number; secrets: Array<{ path: string[]; set: boolean }> }> }; error?: { message?: string } } }>
|
|
35
|
+
update(request: { ns: string; patch: Record<string, unknown>; expectedRevision?: number }): Promise<{ result: { ok: boolean; error?: { message?: string } } }>
|
|
36
|
+
replace(request: { ns: string; section: Record<string, unknown>; expectedRevision?: number }): Promise<{ result: { ok: boolean; error?: { message?: string } } }>
|
|
37
|
+
}
|
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
|
@@ -44,90 +44,7 @@ export const Config = z.object({
|
|
|
44
44
|
export type NotifyEmailConfig = Schemastery.TypeT<typeof Config>
|
|
45
45
|
export type TriggerToggles = NotifyEmailConfig['triggers']
|
|
46
46
|
|
|
47
|
-
/** 配置卡片读取用的传输对象:pass 永不回传,只报告是否已配置。 */
|
|
48
|
-
export interface WireConfig {
|
|
49
|
-
enabled: boolean
|
|
50
|
-
smtp: {
|
|
51
|
-
host: string
|
|
52
|
-
port: number
|
|
53
|
-
secure: boolean
|
|
54
|
-
user: string
|
|
55
|
-
from: string
|
|
56
|
-
passConfigured: boolean
|
|
57
|
-
}
|
|
58
|
-
to: string[]
|
|
59
|
-
triggers: TriggerToggles
|
|
60
|
-
idleDebounceMs: number
|
|
61
|
-
maxBodyChars: number
|
|
62
|
-
dryRun: boolean
|
|
63
|
-
/** 是否存在可写的 settings provider(决定卡片是否允许编辑)。 */
|
|
64
|
-
writable: boolean
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
export function toWire(cfg: NotifyEmailConfig, writable: boolean): WireConfig {
|
|
68
|
-
return {
|
|
69
|
-
enabled: cfg.enabled,
|
|
70
|
-
smtp: {
|
|
71
|
-
host: cfg.smtp.host,
|
|
72
|
-
port: cfg.smtp.port,
|
|
73
|
-
secure: cfg.smtp.secure,
|
|
74
|
-
user: cfg.smtp.user,
|
|
75
|
-
from: cfg.smtp.from,
|
|
76
|
-
passConfigured: cfg.smtp.pass.length > 0,
|
|
77
|
-
},
|
|
78
|
-
to: [...cfg.to],
|
|
79
|
-
triggers: { ...cfg.triggers },
|
|
80
|
-
idleDebounceMs: cfg.idleDebounceMs,
|
|
81
|
-
maxBodyChars: cfg.maxBodyChars,
|
|
82
|
-
dryRun: cfg.dryRun,
|
|
83
|
-
writable,
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
|
|
87
47
|
/** 是否具备发信条件(enabled 之外的最小完整性)。 */
|
|
88
48
|
export function isDeliverable(cfg: NotifyEmailConfig): boolean {
|
|
89
49
|
return cfg.enabled && cfg.smtp.host.length > 0 && cfg.smtp.from.length > 0 && cfg.to.length > 0
|
|
90
50
|
}
|
|
91
|
-
|
|
92
|
-
/**
|
|
93
|
-
* 配置卡片写回用的 patch schema:字段全部可选(无默认值),
|
|
94
|
-
* 只携带用户真实编辑过的字段,交给 settings.update 深合并进用户层。
|
|
95
|
-
*/
|
|
96
|
-
export const WirePatch = z.object({
|
|
97
|
-
enabled: z.boolean(),
|
|
98
|
-
smtp: z.object({
|
|
99
|
-
host: z.string(),
|
|
100
|
-
port: z.natural().max(65535),
|
|
101
|
-
secure: z.boolean(),
|
|
102
|
-
user: z.string(),
|
|
103
|
-
pass: z.string(),
|
|
104
|
-
from: z.string(),
|
|
105
|
-
}),
|
|
106
|
-
to: z.array(z.string()),
|
|
107
|
-
triggers: z.object({
|
|
108
|
-
onComplete: z.boolean(),
|
|
109
|
-
onError: z.boolean(),
|
|
110
|
-
onAborted: z.boolean(),
|
|
111
|
-
onQuestion: z.boolean(),
|
|
112
|
-
onPlanReview: z.boolean(),
|
|
113
|
-
}),
|
|
114
|
-
idleDebounceMs: z.natural(),
|
|
115
|
-
maxBodyChars: z.natural().min(200),
|
|
116
|
-
dryRun: z.boolean(),
|
|
117
|
-
})
|
|
118
|
-
|
|
119
|
-
export type WirePatchInput = Schemastery.TypeS<typeof WirePatch>
|
|
120
|
-
|
|
121
|
-
/**
|
|
122
|
-
* 把卡片提交的 patch 规整为 settings.update 的用户层 patch。
|
|
123
|
-
* smtp.pass 为空串表示「保持不变」,从 patch 中剔除,避免覆盖已存密码。
|
|
124
|
-
*/
|
|
125
|
-
export function toUserPatch(input: WirePatchInput): Record<string, unknown> {
|
|
126
|
-
const patch: Record<string, unknown> = { ...input }
|
|
127
|
-
if (input.smtp !== undefined && input.smtp !== null) {
|
|
128
|
-
const smtp: Record<string, unknown> = { ...input.smtp }
|
|
129
|
-
if (smtp['pass'] === '') delete smtp['pass']
|
|
130
|
-
patch['smtp'] = smtp
|
|
131
|
-
}
|
|
132
|
-
return patch
|
|
133
|
-
}
|
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
|
|