@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 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 draftFromWire(wire) {
119
+ function draftFromValue(value) {
120
120
  return {
121
- enabled: wire.enabled,
122
- host: wire.smtp.host,
123
- port: String(wire.smtp.port),
124
- secure: wire.smtp.secure,
125
- 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,
126
126
  pass: "",
127
- from: wire.smtp.from,
128
- toText: wire.to.join(", "),
129
- triggers: { ...wire.triggers },
130
- idleDebounceMs: String(wire.idleDebounceMs),
131
- maxBodyChars: String(wire.maxBodyChars),
132
- 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
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 [failed, setFailed] = (0, react.useState)(false);
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
- fetchConfig().then((loaded) => {
175
- if (!alive) return;
176
- setWire(loaded);
177
- setDraft(draftFromWire(loaded));
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)(() => 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]);
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 (failed) return null;
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, value) => {
197
+ const edit = (key, editValue) => {
196
198
  setDraft({
197
199
  ...draft,
198
- [key]: value
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
- saveConfig(toPatch(draft)).then((saved) => {
211
- setWire(saved);
212
- 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));
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")} ${error instanceof Error ? error.message : ""}`
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 = !wire.writable;
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: wire.smtp.passConfigured ? t("passSet") : t("passUnset"),
329
- set: wire.smtp.passConfigured
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(draftFromWire(wire));
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 = ["slots", "locale"];
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
- id: "notify-email",
612
- order: 100,
630
+ key: SETTINGS_NS,
613
631
  locale: NS,
614
- inject: () => ({ t: c.locale.bind(NS) })
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("dsh-plus-notify-email");
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
- /** 注册配置读写与测试发信路由;返回是否注册成功(webServer 缺失时由调用方保证不调用)。 */
269
- function registerConfigApi(ctx, service) {
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(`config api ${req.method ?? "?"} ${req.url ?? "?"} failed: ${message}`);
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
- registerConfigApi(webCtx, this);
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.0",
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.6",
35
- "@deepseek-ai/dsh-settings": "0.1.0-rc.6",
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.6",
41
- "@deepseek-ai/dsh-host-webserver": "0.1.0-rc.6",
42
- "@deepseek-ai/dsh-llm": "0.1.0-rc.6",
43
- "@deepseek-ai/dsh-session": "0.1.0-rc.6",
44
- "@deepseek-ai/dsh-tools": "0.1.0-rc.6",
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-plugins.git",
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
- * 配置卡片数据通道:同源 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,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
- * 避免为构建期类型引入整条官方 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 服务为准。
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
- id: 'notify-email',
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
- * 配置 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
@@ -7,8 +7,10 @@
7
7
  import z from '@deepseek-ai/schemastery'
8
8
  import { settingsNamespace } from '@deepseek-ai/dsh-settings'
9
9
 
10
- /** settings 命名空间;webui 配置卡片与插件运行期读取同一份。 */
11
- export const SETTINGS_NS = settingsNamespace('dsh-plus-notify-email')
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 { 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