@dsh-plus/notify-email 0.1.1 → 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 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 draftFromWire(wire) {
119
+ function draftFromValue(value) {
132
120
  return {
133
- enabled: wire.enabled,
134
- host: wire.smtp.host,
135
- port: String(wire.smtp.port),
136
- secure: wire.smtp.secure,
137
- user: wire.smtp.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: wire.smtp.from,
140
- toText: wire.to.join(", "),
141
- triggers: { ...wire.triggers },
142
- idleDebounceMs: String(wire.idleDebounceMs),
143
- maxBodyChars: String(wire.maxBodyChars),
144
- dryRun: wire.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 [failed, setFailed] = (0, react.useState)(false);
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
- fetchConfig().then((loaded) => {
187
- if (!alive) return;
188
- setWire(loaded);
189
- setDraft(draftFromWire(loaded));
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)(() => wire !== null && draft !== null && JSON.stringify(toPatch(draft)) !== JSON.stringify(toPatch(draftFromWire(wire))), [wire, draft]);
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 (failed) return null;
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, value) => {
197
+ const edit = (key, editValue) => {
208
198
  setDraft({
209
199
  ...draft,
210
- [key]: value
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
- saveConfig(toPatch(draft)).then((saved) => {
223
- setWire(saved);
224
- setDraft(draftFromWire(saved));
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")} ${error instanceof Error ? error.message : ""}`
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 = !wire.writable;
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: wire.smtp.passConfigured ? t("passSet") : t("passUnset"),
341
- set: wire.smtp.passConfigured
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(draftFromWire(wire));
421
+ setDraft(draftFromValue(value));
422
422
  setStatus(IDLE_STATUS);
423
423
  },
424
424
  children: t("discard")
@@ -607,7 +607,12 @@ 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 = ["slots", "locale"];
610
+ const inject = [
611
+ "slots",
612
+ "locale",
613
+ "settingsScope",
614
+ "connection"
615
+ ];
611
616
  function apply(ctx) {
612
617
  const c = ctx;
613
618
  const tag = injectStyle();
@@ -618,12 +623,17 @@ function apply(ctx) {
618
623
  zh,
619
624
  en
620
625
  }), "notify-email: locale");
626
+ const scope = c.settingsScope.bind({ namespace: SETTINGS_NS });
627
+ const api = c.get("connection").api.settings;
621
628
  c.slots.inject("settings.plugin.item", () => c.slots.register({
622
629
  name: "settings.plugin.item",
623
630
  key: SETTINGS_NS,
624
- order: 100,
625
631
  locale: NS,
626
- inject: () => ({ t: c.locale.bind(NS) })
632
+ inject: () => ({
633
+ t: c.locale.bind(NS),
634
+ scope,
635
+ api
636
+ })
627
637
  }, NotifyEmailCard));
628
638
  }
629
639
 
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
- /** 注册配置读写与测试发信路由;返回是否注册成功(webServer 缺失时由调用方保证不调用)。 */
281
- function registerConfigApi(ctx, service) {
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(`config api ${req.method ?? "?"} ${req.url ?? "?"} failed: ${message}`);
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
- registerConfigApi(webCtx, this);
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.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,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-client-ui-settings"
28
30
  ],
29
31
  "platform": "web"
30
32
  }
package/src/client/api.ts CHANGED
@@ -1,60 +1,14 @@
1
1
  /**
2
- * 配置卡片数据通道:同源 fetch 调自建 webServer 路由。
3
- * (官方 settings.* RPC 白名单硬编码不含第三方 namespace,见 config-api.ts 注释。)
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
  }
@@ -1,18 +1,46 @@
1
1
  /**
2
2
  * 「邮件通知」配置卡片:注册进 settings.plugin.item 插槽(官方插件配置页)。
3
3
  * 交互对齐官方卡片:折叠/展开、staged draft、未保存标记、保存/放弃;
4
- * 另加「发送测试邮件」。数据走自建同源路由(api.ts)。
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
- fetchConfig, saveConfig, sendTest, type WireConfig, type WirePatch, type WireTriggers,
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: WireTriggers
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 draftFromWire(wire: WireConfig): Draft {
68
+ function draftFromValue(value: ConfigValue): Draft {
41
69
  return {
42
- enabled: wire.enabled,
43
- host: wire.smtp.host,
44
- port: String(wire.smtp.port),
45
- secure: wire.smtp.secure,
46
- user: wire.smtp.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: wire.smtp.from,
49
- toText: wire.to.join(', '),
50
- triggers: { ...wire.triggers },
51
- idleDebounceMs: String(wire.idleDebounceMs),
52
- maxBodyChars: String(wire.maxBodyChars),
53
- dryRun: wire.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): WirePatch {
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 [failed, setFailed] = useState(false)
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
- fetchConfig()
95
- .then((loaded) => {
96
- if (!alive) return
97
- setWire(loaded)
98
- setDraft(draftFromWire(loaded))
99
- })
100
- .catch(() => {
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
- () => wire !== null && draft !== null && JSON.stringify(toPatch(draft)) !== JSON.stringify(toPatch(draftFromWire(wire))),
110
- [wire, draft],
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 (failed) return null
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, value: Draft[K]): void => {
125
- setDraft({ ...draft, [key]: value })
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: keyof WireTriggers, checked: boolean): void => {
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
- saveConfig(toPatch(draft))
134
- .then((saved) => {
135
- setWire(saved)
136
- setDraft(draftFromWire(saved))
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
- setStatus({ kind: 'error', text: `${t('saveFailed')} ${error instanceof Error ? error.message : ''}` })
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 = !wire.writable
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: wire.smtp.passConfigured ? t('passSet') : t('passUnset'), set: wire.smtp.passConfigured }}
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(draftFromWire(wire)); setStatus(IDLE_STATUS) }}>
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}
@@ -3,12 +3,15 @@
3
3
  * 构建产物为 window.__ModuleLoader__.load({id, factory}) 形式的 CJS factory
4
4
  * (包装见 tsdown.config.ts);样式沿用官方 data-plugin-css 约定,HMR 据此卸载。
5
5
  *
6
- * 类型说明:浏览器半只用到 slots/locale 的很窄一面,此处以最小本地接口声明,
7
- * 避免为构建期类型引入整条官方 client 依赖树;运行时契约以官方
8
- * dsh-client-ui-settings-plugins 的 settings.plugin.item 插槽为准。
6
+ * 类型说明:浏览器半只用到 slots/locale/settingsScope/connection 的很窄一面,
7
+ * 此处以最小本地接口声明(见 ./scope.ts),避免为构建期类型引入整条官方
8
+ * client 依赖树;运行时契约以官方 dsh-client-ui-settings-plugins 的
9
+ * settings.plugin.item 插槽与 dsh-client-ui-settings 的 settingsScope 服务为准。
9
10
  * rc7 起该插槽为 keyed 槽位:key 必须是卡片编辑的 settings 命名空间
10
11
  * (即服务端 settingsNamespace() 注册的同一字面量,见 ../ns.ts),
11
12
  * 官方配置页按此 key 与 Host 已注册命名空间配对分发。
13
+ * 配置读写经官方 settingsScope 传输(rc7 起第三方命名空间对 settings RPC
14
+ * 全量开放);自定义端点仅剩「发送测试邮件」(api.ts)。
12
15
  * @module @dsh-plus/notify-email/client
13
16
  */
14
17
  import type { Context } from '@deepseek-ai/cordis'
@@ -16,12 +19,13 @@ import type { Context } from '@deepseek-ai/cordis'
16
19
  import { SETTINGS_NS } from '../ns.ts'
17
20
  import { NotifyEmailCard } from './card.tsx'
18
21
  import { en, NS, zh } from './i18n.ts'
22
+ import type { Scope, SettingsApi } from './scope.ts'
19
23
  import { injectStyle } from './styles.ts'
20
24
 
21
25
  export const name = 'dsh-plus-notify-email'
22
26
 
23
27
  /** 浏览器半需要的 cordis 服务 key(loader 据此注入;package.json 的 dsh.client.inject 管包加载顺序)。 */
24
- export const inject = ['slots', 'locale'] as const
28
+ export const inject = ['slots', 'locale', 'settingsScope', 'connection'] as const
25
29
 
26
30
  interface SlotsLike {
27
31
  inject(key: string, callback: () => unknown): unknown
@@ -33,9 +37,19 @@ interface LocaleLike {
33
37
  bind(ns: string): (key: string) => string
34
38
  }
35
39
 
40
+ interface SettingsScopeServiceLike {
41
+ bind(spec: { namespace: string }): Scope
42
+ }
43
+
44
+ interface ConnectionLike {
45
+ settings: SettingsApi
46
+ }
47
+
36
48
  interface ClientContext {
37
49
  slots: SlotsLike
38
50
  locale: LocaleLike
51
+ settingsScope: SettingsScopeServiceLike
52
+ get(key: 'connection'): ConnectionLike
39
53
  effect(execute: () => () => void, label?: string): unknown
40
54
  }
41
55
 
@@ -52,14 +66,15 @@ export function apply(ctx: Context): void {
52
66
  () => c.locale.register(NS, { zh, en }),
53
67
  'notify-email: locale',
54
68
  )
69
+ const scope = c.settingsScope.bind({ namespace: SETTINGS_NS })
70
+ const api = c.get('connection').api.settings
55
71
  c.slots.inject('settings.plugin.item', () =>
56
72
  c.slots.register(
57
73
  {
58
74
  name: 'settings.plugin.item',
59
75
  key: SETTINGS_NS,
60
- order: 100,
61
76
  locale: NS,
62
- inject: () => ({ t: c.locale.bind(NS) }),
77
+ inject: () => ({ t: c.locale.bind(NS), scope, api }),
63
78
  },
64
79
  NotifyEmailCard,
65
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
- * 配置 HTTP 通道:webui 插件配置卡片的后端。
3
- * 背景:官方 apiproxy 的 settings.* RPC 对 namespace 有硬编码白名单,第三方
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
- /** 注册配置读写与测试发信路由;返回是否注册成功(webServer 缺失时由调用方保证不调用)。 */
84
- export function registerConfigApi(ctx: Context, service: NotifyEmailService): void {
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(`config api ${req.method ?? '?'} ${req.url ?? '?'} failed: ${message}`)
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 { registerConfigApi } from './config-api.ts'
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
- registerConfigApi(webCtx, this)
55
+ registerTestApi(webCtx, this)
56
56
  })
57
57
  }
58
58