@dsh-plus/notify-email 0.1.6 → 0.1.7

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/index.d.ts CHANGED
@@ -1,5 +1,5 @@
1
- import z from "@deepseek-ai/schemastery";
2
1
  import "@deepseek-ai/dsh-settings";
2
+ import z from "@deepseek-ai/schemastery";
3
3
  import { Context, Service } from "@deepseek-ai/cordis";
4
4
 
5
5
  //#region src/config.d.ts
package/lib/index.js CHANGED
@@ -1,5 +1,5 @@
1
- import z from "@deepseek-ai/schemastery";
2
1
  import { installSettingsSection, settingsNamespace } from "@deepseek-ai/dsh-settings";
2
+ import z from "@deepseek-ai/schemastery";
3
3
  import { Context, Service } from "@deepseek-ai/cordis";
4
4
  import { dshHomePath } from "@deepseek-ai/dsh-home-paths";
5
5
  import { appendFile, mkdir } from "node:fs/promises";
@@ -81,6 +81,45 @@ function createJsonlAuditSink(path, onError) {
81
81
  };
82
82
  }
83
83
 
84
+ //#endregion
85
+ //#region src/config-api.ts
86
+ const ROUTE_TEST = "/dsh-plus/notify-email/test";
87
+ function sendJson(res, status, body) {
88
+ res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
89
+ res.end(JSON.stringify(body));
90
+ }
91
+ async function handleTest(service, res) {
92
+ const result = await service.sendTest();
93
+ sendJson(res, result.ok ? 200 : 502, result);
94
+ }
95
+ /** 注册测试发信路由;返回是否注册成功(webServer 缺失时由调用方保证不调用)。 */
96
+ function registerTestApi(ctx, service) {
97
+ const logger = ctx.logger("notify-email");
98
+ const guard = (handler) => {
99
+ return async (req, res) => {
100
+ try {
101
+ await handler(req, res);
102
+ } catch (error) {
103
+ const message = error instanceof Error ? error.message : String(error);
104
+ logger.warn(`test api ${req.method ?? "?"} ${req.url ?? "?"} failed: ${message}`);
105
+ if (!res.headersSent) sendJson(res, 400, { error: message });
106
+ else res.end();
107
+ }
108
+ };
109
+ };
110
+ ctx.webServer.register({
111
+ kind: "exact",
112
+ path: ROUTE_TEST,
113
+ handler: guard(async (req, res) => {
114
+ if (req.method !== "POST") {
115
+ sendJson(res, 405, { error: "method not allowed" });
116
+ return;
117
+ }
118
+ await handleTest(service, res);
119
+ })
120
+ });
121
+ }
122
+
84
123
  //#endregion
85
124
  //#region src/mailer.ts
86
125
  /** nodemailer 连接/问候/套接字超时(毫秒),防 SMTP 不可达时挂住通知链路。 */
@@ -161,45 +200,6 @@ var Mailer = class {
161
200
  }
162
201
  };
163
202
 
164
- //#endregion
165
- //#region src/config-api.ts
166
- const ROUTE_TEST = "/dsh-plus/notify-email/test";
167
- function sendJson(res, status, body) {
168
- res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
169
- res.end(JSON.stringify(body));
170
- }
171
- async function handleTest(service, res) {
172
- const result = await service.sendTest();
173
- sendJson(res, result.ok ? 200 : 502, result);
174
- }
175
- /** 注册测试发信路由;返回是否注册成功(webServer 缺失时由调用方保证不调用)。 */
176
- function registerTestApi(ctx, service) {
177
- const logger = ctx.logger("notify-email");
178
- const guard = (handler) => {
179
- return async (req, res) => {
180
- try {
181
- await handler(req, res);
182
- } catch (error) {
183
- const message = error instanceof Error ? error.message : String(error);
184
- logger.warn(`test api ${req.method ?? "?"} ${req.url ?? "?"} failed: ${message}`);
185
- if (!res.headersSent) sendJson(res, 400, { error: message });
186
- else res.end();
187
- }
188
- };
189
- };
190
- ctx.webServer.register({
191
- kind: "exact",
192
- path: ROUTE_TEST,
193
- handler: guard(async (req, res) => {
194
- if (req.method !== "POST") {
195
- sendJson(res, 405, { error: "method not allowed" });
196
- return;
197
- }
198
- await handleTest(service, res);
199
- })
200
- });
201
- }
202
-
203
203
  //#endregion
204
204
  //#region src/triggers/render.ts
205
205
  function truncate(text, maxChars) {
@@ -456,7 +456,7 @@ var NotifyEmailService = class extends Service {
456
456
  async sendNotice(notice) {
457
457
  return this.mailer.send(notice);
458
458
  }
459
- async dispatch(kind, payload, produce) {
459
+ async dispatch(kind, _payload, produce) {
460
460
  for (const trigger of [...this.triggers]) {
461
461
  let notice;
462
462
  try {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-plus/notify-email",
3
- "version": "0.1.6",
3
+ "version": "0.1.7",
4
4
  "description": "dsh-plus service+ui plugin: 任务结束/等待决策/出错时向指定邮箱发送邮件通知",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -7,7 +7,7 @@
7
7
  * 保存经 settings.update 深合并(空 pass 剔除 = 保持不变)。
8
8
  * @module notify-email/client/card
9
9
  */
10
- import { useEffect, useMemo, useSyncExternalStore, useState, type ReactElement } from 'react'
10
+ import { type ReactElement, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
11
11
 
12
12
  import { SETTINGS_NS } from '../ns.ts'
13
13
  import { sendTest } from './api.ts'
@@ -97,7 +97,10 @@ function toPatch(draft: Draft): Record<string, unknown> {
97
97
  pass: draft.pass,
98
98
  from: draft.from.trim(),
99
99
  },
100
- to: draft.toText.split(',').map((s) => s.trim()).filter((s) => s.length > 0),
100
+ to: draft.toText
101
+ .split(',')
102
+ .map((s) => s.trim())
103
+ .filter((s) => s.length > 0),
101
104
  triggers: { ...draft.triggers },
102
105
  idleDebounceMs: Number(draft.idleDebounceMs),
103
106
  maxBodyChars: Number(draft.maxBodyChars),
@@ -128,14 +131,17 @@ export function NotifyEmailCard(props: CardProps): ReactElement | null {
128
131
  }, [value, draft])
129
132
 
130
133
  // passConfigured 探测:scope 快照不含 secrets,经 describe 的 secrets 列表判断。
134
+ // biome-ignore lint/correctness/useExhaustiveDependencies: snapshot 变化(保存后 revision 推进)时刻意重探 secrets 状态
131
135
  useEffect(() => {
132
136
  let alive = true
133
- api.describe({})
137
+ api
138
+ .describe({})
134
139
  .then((response) => {
135
140
  if (!alive || !response.result.ok) return
136
141
  const view = response.result.value?.namespaces.find((ns) => ns.ns === SETTINGS_NS)
137
142
  setPassConfigured(
138
- view?.secrets.some((secret) => secret.path.join('.') === 'smtp.pass' && secret.set) ?? false,
143
+ view?.secrets.some((secret) => secret.path.join('.') === 'smtp.pass' && secret.set) ??
144
+ false,
139
145
  )
140
146
  })
141
147
  .catch(() => {})
@@ -146,20 +152,27 @@ export function NotifyEmailCard(props: CardProps): ReactElement | null {
146
152
 
147
153
  const dirty = useMemo(
148
154
  () =>
149
- value !== undefined && draft !== null &&
155
+ value !== undefined &&
156
+ draft !== null &&
150
157
  JSON.stringify(toPatch(draft)) !== JSON.stringify(toPatch(draftFromValue(value))),
151
158
  [value, draft],
152
159
  )
153
160
  const invalid = useMemo(
154
161
  () =>
155
162
  draft !== null &&
156
- (!isPositiveInt(draft.port) || !isPositiveInt(draft.idleDebounceMs) ||
157
- !isPositiveInt(draft.maxBodyChars) || Number(draft.maxBodyChars) < 200),
163
+ (!isPositiveInt(draft.port) ||
164
+ !isPositiveInt(draft.idleDebounceMs) ||
165
+ !isPositiveInt(draft.maxBodyChars) ||
166
+ Number(draft.maxBodyChars) < 200),
158
167
  [draft],
159
168
  )
160
169
 
161
170
  if (value === undefined || draft === null) {
162
- return <li className="dne-card"><p className="dne-readOnly">{t('loading')}</p></li>
171
+ return (
172
+ <li className="dne-card">
173
+ <p className="dne-readOnly">{t('loading')}</p>
174
+ </li>
175
+ )
163
176
  }
164
177
  const edit = <K extends keyof Draft>(key: K, editValue: Draft[K]): void => {
165
178
  setDraft({ ...draft, [key]: editValue })
@@ -175,11 +188,12 @@ export function NotifyEmailCard(props: CardProps): ReactElement | null {
175
188
  delete (patch['smtp'] as Record<string, unknown>)['pass']
176
189
  }
177
190
  const revision = scope.getSnapshot().revision
178
- api.update({
179
- ns: SETTINGS_NS,
180
- patch,
181
- ...(revision !== undefined ? { expectedRevision: revision } : {}),
182
- })
191
+ api
192
+ .update({
193
+ ns: SETTINGS_NS,
194
+ patch,
195
+ ...(revision !== undefined ? { expectedRevision: revision } : {}),
196
+ })
183
197
  .then(async (response) => {
184
198
  if (!response.result.ok) {
185
199
  throw new Error(response.result.error?.message ?? t('saveFailed'))
@@ -200,14 +214,20 @@ export function NotifyEmailCard(props: CardProps): ReactElement | null {
200
214
  sendTest()
201
215
  .then((result) => {
202
216
  if (result.ok) {
203
- setStatus({ kind: 'ok', text: result.detail === 'dry-run' ? t('testDryRun') : t('testOk') })
217
+ setStatus({
218
+ kind: 'ok',
219
+ text: result.detail === 'dry-run' ? t('testDryRun') : t('testOk'),
220
+ })
204
221
  } else {
205
222
  const why = result.detail === 'incomplete' ? t('incomplete') : result.detail
206
223
  setStatus({ kind: 'error', text: `${t('testFailed')}${why}` })
207
224
  }
208
225
  })
209
226
  .catch((error: unknown) => {
210
- setStatus({ kind: 'error', text: `${t('testFailed')}${error instanceof Error ? error.message : ''}` })
227
+ setStatus({
228
+ kind: 'error',
229
+ text: `${t('testFailed')}${error instanceof Error ? error.message : ''}`,
230
+ })
211
231
  })
212
232
  .finally(() => setTesting(false))
213
233
  }
@@ -231,58 +251,155 @@ export function NotifyEmailCard(props: CardProps): ReactElement | null {
231
251
  </button>
232
252
  {open ? (
233
253
  <div className="dne-body">
234
- {disabled ? <p className="dne-readOnly" role="status">{t('readOnly')}</p> : null}
235
- <CheckRow id="dne-enabled" label={t('enabled')} checked={draft.enabled} disabled={disabled}
236
- onEdit={(v) => edit('enabled', v)} />
237
- <TextField id="dne-host" label={t('host')} hint={t('hostHint')} value={draft.host}
238
- disabled={disabled} onEdit={(v) => edit('host', v)} />
239
- <TextField id="dne-port" label={t('port')} hint={t('portHint')} value={draft.port} numeric
240
- disabled={disabled} invalid={!isPositiveInt(draft.port)} invalidLabel={t('invalidNumber')}
241
- onEdit={(v) => edit('port', v)} />
242
- <CheckRow id="dne-secure" label={t('secure')} checked={draft.secure} disabled={disabled}
243
- onEdit={(v) => edit('secure', v)} />
244
- <TextField id="dne-user" label={t('user')} hint={t('userHint')} value={draft.user}
245
- disabled={disabled} onEdit={(v) => edit('user', v)} />
246
- <TextField id="dne-pass" label={t('pass')} hint={t('passHint')} value={draft.pass} password
254
+ {disabled ? (
255
+ <p className="dne-readOnly" role="status">
256
+ {t('readOnly')}
257
+ </p>
258
+ ) : null}
259
+ <CheckRow
260
+ id="dne-enabled"
261
+ label={t('enabled')}
262
+ checked={draft.enabled}
263
+ disabled={disabled}
264
+ onEdit={(v) => edit('enabled', v)}
265
+ />
266
+ <TextField
267
+ id="dne-host"
268
+ label={t('host')}
269
+ hint={t('hostHint')}
270
+ value={draft.host}
271
+ disabled={disabled}
272
+ onEdit={(v) => edit('host', v)}
273
+ />
274
+ <TextField
275
+ id="dne-port"
276
+ label={t('port')}
277
+ hint={t('portHint')}
278
+ value={draft.port}
279
+ numeric
280
+ disabled={disabled}
281
+ invalid={!isPositiveInt(draft.port)}
282
+ invalidLabel={t('invalidNumber')}
283
+ onEdit={(v) => edit('port', v)}
284
+ />
285
+ <CheckRow
286
+ id="dne-secure"
287
+ label={t('secure')}
288
+ checked={draft.secure}
289
+ disabled={disabled}
290
+ onEdit={(v) => edit('secure', v)}
291
+ />
292
+ <TextField
293
+ id="dne-user"
294
+ label={t('user')}
295
+ hint={t('userHint')}
296
+ value={draft.user}
247
297
  disabled={disabled}
248
- badge={{ text: passConfigured ? t('passSet') : t('passUnset'), set: passConfigured }}
249
- onEdit={(v) => edit('pass', v)} />
250
- <TextField id="dne-from" label={t('from')} hint={t('fromHint')} value={draft.from}
251
- disabled={disabled} onEdit={(v) => edit('from', v)} />
252
- <TextField id="dne-to" label={t('to')} hint={t('toHint')} value={draft.toText}
253
- disabled={disabled} onEdit={(v) => edit('toText', v)} />
298
+ onEdit={(v) => edit('user', v)}
299
+ />
300
+ <TextField
301
+ id="dne-pass"
302
+ label={t('pass')}
303
+ hint={t('passHint')}
304
+ value={draft.pass}
305
+ password
306
+ disabled={disabled}
307
+ badge={{
308
+ text: passConfigured ? t('passSet') : t('passUnset'),
309
+ set: passConfigured,
310
+ }}
311
+ onEdit={(v) => edit('pass', v)}
312
+ />
313
+ <TextField
314
+ id="dne-from"
315
+ label={t('from')}
316
+ hint={t('fromHint')}
317
+ value={draft.from}
318
+ disabled={disabled}
319
+ onEdit={(v) => edit('from', v)}
320
+ />
321
+ <TextField
322
+ id="dne-to"
323
+ label={t('to')}
324
+ hint={t('toHint')}
325
+ value={draft.toText}
326
+ disabled={disabled}
327
+ onEdit={(v) => edit('toText', v)}
328
+ />
254
329
  <p className="dne-groupLabel">{t('triggerGroup')}</p>
255
330
  {TRIGGER_KEYS.map((key) => (
256
- <CheckRow key={key} id={`dne-${key}`} label={t(key)} checked={draft.triggers[key]}
257
- disabled={disabled} onEdit={(v) => editTrigger(key, v)} />
331
+ <CheckRow
332
+ key={key}
333
+ id={`dne-${key}`}
334
+ label={t(key)}
335
+ checked={draft.triggers[key]}
336
+ disabled={disabled}
337
+ onEdit={(v) => editTrigger(key, v)}
338
+ />
258
339
  ))}
259
- <TextField id="dne-debounce" label={t('idleDebounceMs')} hint={t('idleDebounceMsHint')}
260
- value={draft.idleDebounceMs} numeric disabled={disabled}
261
- invalid={!isPositiveInt(draft.idleDebounceMs)} invalidLabel={t('invalidNumber')}
262
- onEdit={(v) => edit('idleDebounceMs', v)} />
263
- <TextField id="dne-maxchars" label={t('maxBodyChars')} hint={t('maxBodyCharsHint')}
264
- value={draft.maxBodyChars} numeric disabled={disabled}
340
+ <TextField
341
+ id="dne-debounce"
342
+ label={t('idleDebounceMs')}
343
+ hint={t('idleDebounceMsHint')}
344
+ value={draft.idleDebounceMs}
345
+ numeric
346
+ disabled={disabled}
347
+ invalid={!isPositiveInt(draft.idleDebounceMs)}
348
+ invalidLabel={t('invalidNumber')}
349
+ onEdit={(v) => edit('idleDebounceMs', v)}
350
+ />
351
+ <TextField
352
+ id="dne-maxchars"
353
+ label={t('maxBodyChars')}
354
+ hint={t('maxBodyCharsHint')}
355
+ value={draft.maxBodyChars}
356
+ numeric
357
+ disabled={disabled}
265
358
  invalid={!isPositiveInt(draft.maxBodyChars) || Number(draft.maxBodyChars) < 200}
266
359
  invalidLabel={t('invalidNumber')}
267
- onEdit={(v) => edit('maxBodyChars', v)} />
268
- <CheckRow id="dne-dryrun" label={t('dryRun')} checked={draft.dryRun} disabled={disabled}
269
- onEdit={(v) => edit('dryRun', v)} />
360
+ onEdit={(v) => edit('maxBodyChars', v)}
361
+ />
362
+ <CheckRow
363
+ id="dne-dryrun"
364
+ label={t('dryRun')}
365
+ checked={draft.dryRun}
366
+ disabled={disabled}
367
+ onEdit={(v) => edit('dryRun', v)}
368
+ />
270
369
  <div className="dne-footer">
271
370
  {status.kind !== 'idle' ? (
272
- <p className={`dne-status${status.kind === 'error' ? ' dne-statusError' : ''}`} role="status">
371
+ <p
372
+ className={`dne-status${status.kind === 'error' ? ' dne-statusError' : ''}`}
373
+ role="status"
374
+ >
273
375
  {status.text}
274
376
  </p>
275
377
  ) : null}
276
- <button type="button" className="dne-btn dne-btnGhost" disabled={testing}
277
- onClick={onTest}>
378
+ <button
379
+ type="button"
380
+ className="dne-btn dne-btnGhost"
381
+ disabled={testing}
382
+ onClick={onTest}
383
+ >
278
384
  {t(testing ? 'testing' : 'test')}
279
385
  </button>
280
- <button type="button" className="dne-btn dne-btnGhost" disabled={!dirty || saving}
281
- onClick={() => { setDraft(draftFromValue(value)); setStatus(IDLE_STATUS) }}>
386
+ <button
387
+ type="button"
388
+ className="dne-btn dne-btnGhost"
389
+ disabled={!dirty || saving}
390
+ onClick={() => {
391
+ setDraft(draftFromValue(value))
392
+ setStatus(IDLE_STATUS)
393
+ }}
394
+ >
282
395
  {t('discard')}
283
396
  </button>
284
- <button type="button" className="dne-btn dne-btnPrimary" disabled={!dirty || invalid || saving || disabled}
285
- onClick={onSave}>
397
+ <button
398
+ type="button"
399
+ className="dne-btn dne-btnPrimary"
400
+ disabled={!dirty || invalid || saving || disabled}
401
+ onClick={onSave}
402
+ >
286
403
  {t(saving ? 'saving' : 'save')}
287
404
  </button>
288
405
  </div>
@@ -63,7 +63,12 @@ interface ClientContext {
63
63
  * (loopback 或 tailnet 信任域名)下行为一致。
64
64
  */
65
65
  function createApiScope(api: SettingsApi, ns: string, c: ClientContext): Scope {
66
- let state: ScopeSnapshot = { status: 'loading', value: undefined, revision: undefined, writable: false }
66
+ let state: ScopeSnapshot = {
67
+ status: 'loading',
68
+ value: undefined,
69
+ revision: undefined,
70
+ writable: false,
71
+ }
67
72
  const listeners = new Set<() => void>()
68
73
  let generation = 0
69
74
  const publish = (next: ScopeSnapshot): void => {
@@ -72,7 +77,7 @@ function createApiScope(api: SettingsApi, ns: string, c: ClientContext): Scope {
72
77
  }
73
78
  const load = async (): Promise<void> => {
74
79
  const gen = ++generation
75
- let response
80
+ let response: Awaited<ReturnType<SettingsApi['describe']>>
76
81
  try {
77
82
  response = await api.describe({})
78
83
  } catch {
@@ -82,10 +87,20 @@ function createApiScope(api: SettingsApi, ns: string, c: ClientContext): Scope {
82
87
  const writable = response.result.value?.writable ?? false
83
88
  const view = response.result.value?.namespaces.find((candidate) => candidate.ns === ns)
84
89
  if (view === undefined) {
85
- publish({ status: 'unavailable', value: undefined, revision: undefined, writable })
90
+ publish({
91
+ status: 'unavailable',
92
+ value: undefined,
93
+ revision: undefined,
94
+ writable,
95
+ })
86
96
  return
87
97
  }
88
- publish({ status: 'ready', value: view.value, revision: view.revision, writable })
98
+ publish({
99
+ status: 'ready',
100
+ value: view.value,
101
+ revision: view.revision,
102
+ writable,
103
+ })
89
104
  }
90
105
  const refresh = (namespace?: unknown): void => {
91
106
  if (namespace !== undefined && namespace !== ns) return
@@ -125,10 +140,7 @@ export function apply(ctx: Context): void {
125
140
  },
126
141
  'notify-email: style',
127
142
  )
128
- c.effect(
129
- () => c.locale.register(NS, { zh, en }),
130
- 'notify-email: locale',
131
- )
143
+ c.effect(() => c.locale.register(NS, { zh, en }), 'notify-email: locale')
132
144
  const api = c.get('connection').api.settings
133
145
  const scope = createApiScope(api, SETTINGS_NS, c)
134
146
  c.slots.inject('settings.plugin.item', () =>
@@ -31,7 +31,29 @@ export interface Scope {
31
31
 
32
32
  /** connection api.settings 的 RPC 面(本插件用到的三个方法)。 */
33
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 } } }>
34
+ describe(payload?: Record<string, never>): Promise<{
35
+ result: {
36
+ ok: boolean
37
+ value?: {
38
+ writable: boolean
39
+ namespaces: Array<{
40
+ ns: string
41
+ value: unknown
42
+ revision: number
43
+ secrets: Array<{ path: string[]; set: boolean }>
44
+ }>
45
+ }
46
+ error?: { message?: string }
47
+ }
48
+ }>
49
+ update(request: {
50
+ ns: string
51
+ patch: Record<string, unknown>
52
+ expectedRevision?: number
53
+ }): Promise<{ result: { ok: boolean; error?: { message?: string } } }>
54
+ replace(request: {
55
+ ns: string
56
+ section: Record<string, unknown>
57
+ expectedRevision?: number
58
+ }): Promise<{ result: { ok: boolean; error?: { message?: string } } }>
37
59
  }
package/src/config.ts CHANGED
@@ -4,8 +4,9 @@
4
4
  * 共用同一 schemastery schema。SMTP 密码标记 secret 角色,任何读取通道不得回传。
5
5
  * @module notify-email/config
6
6
  */
7
- import z from '@deepseek-ai/schemastery'
7
+
8
8
  import { settingsNamespace } from '@deepseek-ai/dsh-settings'
9
+ import z from '@deepseek-ai/schemastery'
9
10
 
10
11
  import { SETTINGS_NS as NS_LITERAL } from './ns.ts'
11
12
 
@@ -31,10 +32,21 @@ const TriggerSchema = z.object({
31
32
 
32
33
  export const Config = z.object({
33
34
  enabled: z.boolean().description('总开关').default(false),
34
- smtp: SmtpSchema.default({ host: '', port: 465, secure: true, user: '', pass: '', from: '' }),
35
+ smtp: SmtpSchema.default({
36
+ host: '',
37
+ port: 465,
38
+ secure: true,
39
+ user: '',
40
+ pass: '',
41
+ from: '',
42
+ }),
35
43
  to: z.array(z.string()).description('收件邮箱列表').default([]),
36
44
  triggers: TriggerSchema.default({
37
- onComplete: true, onError: true, onAborted: false, onQuestion: true, onPlanReview: true,
45
+ onComplete: true,
46
+ onError: true,
47
+ onAborted: false,
48
+ onQuestion: true,
49
+ onPlanReview: true,
38
50
  }),
39
51
  idleDebounceMs: z.natural().description('turn 结束后确认无后续工作的等待毫秒数').default(3000),
40
52
  maxBodyChars: z.natural().min(200).description('邮件正文内容截断长度').default(4000),
package/src/index.ts CHANGED
@@ -14,11 +14,16 @@ export const name = 'dsh-plus-notify-email'
14
14
 
15
15
  export const inject = ['agents', 'tools'] as const
16
16
 
17
- export { Config }
18
-
19
- export type { EmailNotice, DecisionCall, TurnEndInfo, NotifyTrigger } from './triggers/types.ts'
20
17
  export { NotifyEmailService } from './service.ts'
21
18
 
19
+ export type {
20
+ DecisionCall,
21
+ EmailNotice,
22
+ NotifyTrigger,
23
+ TurnEndInfo,
24
+ } from './triggers/types.ts'
25
+ export { Config }
26
+
22
27
  export function apply(ctx: Context, config: NotifyEmailConfig): void {
23
28
  ctx.plugin(NotifyEmailService, config)
24
29
  }
package/src/mailer.ts CHANGED
@@ -5,8 +5,8 @@
5
5
  */
6
6
  import nodemailer from 'nodemailer'
7
7
 
8
- import { buildAuditRecord, type AuditSink } from './audit.ts'
9
- import { isDeliverable, type NotifyEmailConfig } from './config.ts'
8
+ import { type AuditSink, buildAuditRecord } from './audit.ts'
9
+ import type { NotifyEmailConfig } from './config.ts'
10
10
 
11
11
  /** 一封待发邮件(触发器产物的最小契约)。 */
12
12
  export interface MailMessage {
package/src/service.ts CHANGED
@@ -8,12 +8,11 @@
8
8
  */
9
9
  import { Context, Service } from '@deepseek-ai/cordis'
10
10
  import { dshHomePath } from '@deepseek-ai/dsh-home-paths'
11
-
12
- import { createJsonlAuditSink } from './audit.ts'
13
- import { Config, SETTINGS_NS, type NotifyEmailConfig } from './config.ts'
14
- import { Mailer } from './mailer.ts'
15
11
  import { installSettingsSection } from '@deepseek-ai/dsh-settings'
12
+ import { createJsonlAuditSink } from './audit.ts'
13
+ import { Config, type NotifyEmailConfig, SETTINGS_NS } from './config.ts'
16
14
  import { registerTestApi } from './config-api.ts'
15
+ import { Mailer } from './mailer.ts'
17
16
  import { createDecisionTrigger, createTurnEndTrigger } from './triggers/builtin.ts'
18
17
  import type { DecisionCall, NotifyTrigger, TurnEndInfo } from './triggers/types.ts'
19
18
  import { installDecisionWatcher } from './watchers/decision.ts'
@@ -42,9 +41,8 @@ export class NotifyEmailService extends Service {
42
41
  onChange: () => {},
43
42
  })
44
43
  const logger = ctx.logger('notify-email')
45
- const audit = createJsonlAuditSink(
46
- dshHomePath('logs', 'notify-email.jsonl'),
47
- (message) => logger.warn(`audit write failed: ${message}`),
44
+ const audit = createJsonlAuditSink(dshHomePath('logs', 'notify-email.jsonl'), (message) =>
45
+ logger.warn(`audit write failed: ${message}`),
48
46
  )
49
47
  this.mailer = new Mailer(() => this.current(), logger, undefined, audit)
50
48
  this.registerTrigger(createDecisionTrigger(() => this.current()))
@@ -89,37 +87,45 @@ export class NotifyEmailService extends Service {
89
87
  /** 配置卡片「发送测试邮件」:绕过 enabled 门禁,仍需 SMTP 完整。 */
90
88
  async sendTest(): Promise<{ ok: boolean; detail: string }> {
91
89
  return this.mailer.send(
92
- { subject: '[DSH] 邮件通知测试', text: '这是一封来自 dsh notify-email 插件的测试邮件。' },
90
+ {
91
+ subject: '[DSH] 邮件通知测试',
92
+ text: '这是一封来自 dsh notify-email 插件的测试邮件。',
93
+ },
93
94
  true,
94
95
  )
95
96
  }
96
97
 
97
98
  /** 通用直发接口(如 lifeboat 故障告警):走正常 enabled/完整性门禁。 */
98
- async sendNotice(notice: { subject: string; text: string }): Promise<{ ok: boolean; detail: string }> {
99
+ async sendNotice(notice: {
100
+ subject: string
101
+ text: string
102
+ }): Promise<{ ok: boolean; detail: string }> {
99
103
  return this.mailer.send(notice)
100
104
  }
101
105
 
102
106
  private async dispatch<T>(
103
107
  kind: string,
104
- payload: T,
108
+ _payload: T,
105
109
  produce: (t: NotifyTrigger) => { subject: string; text: string; html?: string } | undefined,
106
110
  ): Promise<void> {
107
111
  for (const trigger of [...this.triggers]) {
108
- let notice
112
+ let notice: ReturnType<typeof produce>
109
113
  try {
110
114
  notice = produce(trigger)
111
115
  } catch (error) {
112
- this.ctx.logger('notify-email').warn(
113
- `trigger ${trigger.id} failed on ${kind}: ${error instanceof Error ? error.message : String(error)}`,
114
- )
116
+ this.ctx
117
+ .logger('notify-email')
118
+ .warn(
119
+ `trigger ${trigger.id} failed on ${kind}: ${error instanceof Error ? error.message : String(error)}`,
120
+ )
115
121
  continue
116
122
  }
117
123
  if (notice === undefined) continue
118
124
  const result = await this.mailer.send(notice)
119
125
  if (!result.ok && result.detail !== 'disabled' && result.detail !== 'incomplete') {
120
- this.ctx.logger('notify-email').warn(
121
- `deliver failed via trigger ${trigger.id}: ${result.detail}`,
122
- )
126
+ this.ctx
127
+ .logger('notify-email')
128
+ .warn(`deliver failed via trigger ${trigger.id}: ${result.detail}`)
123
129
  }
124
130
  return
125
131
  }
@@ -5,8 +5,11 @@
5
5
  */
6
6
  import type { NotifyEmailConfig } from '../config.ts'
7
7
  import {
8
- renderAbortedNotice, renderCompletionNotice, renderErrorNotice,
9
- renderPlanNotice, renderQuestionNotice,
8
+ renderAbortedNotice,
9
+ renderCompletionNotice,
10
+ renderErrorNotice,
11
+ renderPlanNotice,
12
+ renderQuestionNotice,
10
13
  } from './render.ts'
11
14
  import type { NotifyTrigger } from './types.ts'
12
15
 
@@ -91,13 +91,10 @@ export function installTurnEndWatcher(
91
91
  const watch = watchOf(session.id)
92
92
  if (watch.timer !== undefined) clearTimeout(watch.timer)
93
93
  const errorMessage = extractErrorMessage(reason)
94
- watch.timer = setTimeout(
95
- () => {
96
- watch.timer = undefined
97
- fire(session, turn, reason.kind, errorMessage)
98
- },
99
- resolveConfig().idleDebounceMs,
100
- )
94
+ watch.timer = setTimeout(() => {
95
+ watch.timer = undefined
96
+ fire(session, turn, reason.kind, errorMessage)
97
+ }, resolveConfig().idleDebounceMs)
101
98
  }
102
99
 
103
100
  ctx.on('session/event', (session, event) => {