@dsh-plus/notify-email 0.1.36 → 0.1.38

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dsh-plus/notify-email",
3
- "version": "0.1.36",
3
+ "version": "0.1.38",
4
4
  "description": "dsh-plus service+ui plugin: 任务结束/等待决策/出错时向指定邮箱发送邮件通知",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -32,7 +32,7 @@
32
32
  },
33
33
  "dependencies": {
34
34
  "nodemailer": "9.0.6",
35
- "@dsh-plus/shared": "0.1.30"
35
+ "@dsh-plus/shared": "0.1.32"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@deepseek-ai/cordis": "^4.0.4",
@@ -1,7 +1,6 @@
1
1
  /**
2
- * 「邮件通知」配置卡片:经 injectPluginConfigCard 注册(legacy settings.plugin.item
3
- * 与 0.1.6-alpha.2 插件页 plugins.row.config / plugins.bundle.config,view 分发
4
- * summary/page)。
2
+ * 「邮件通知」配置卡片:经 injectPluginConfigCard 注册(插件页 plugins.row.config /
3
+ * plugins.bundle.config,view 分发 summary/page)。
5
4
  * 外壳与基础控件走 @dsh-plus/shared/client 套件(CardChrome/TextField/CheckRow),
6
5
  * 本文件只保留业务字段与保存/测试逻辑。交互对齐官方卡片:折叠/展开、
7
6
  * staged draft、未保存标记、保存/放弃;另加「发送测试邮件」。
@@ -13,18 +12,28 @@
13
12
  */
14
13
 
15
14
  import {
15
+ type CardAction,
16
16
  CardChrome,
17
+ CardLoading,
17
18
  type CardStatusState,
18
19
  CheckRow,
19
20
  IDLE_STATUS,
20
21
  type NamespaceSettingsApi,
21
22
  type PluginConfigViewProps,
22
23
  type Scope,
23
- TextField,
24
24
  } from '@dsh-plus/shared/client'
25
25
  import { type ReactElement, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
26
26
  import { SETTINGS_NS } from '../ns.ts'
27
27
  import { sendTest } from './api.ts'
28
+ import {
29
+ type ConfigValue,
30
+ type Draft,
31
+ draftFromValue,
32
+ isPositiveInt,
33
+ type TriggerKey,
34
+ toPatch,
35
+ } from './draft.ts'
36
+ import { SmtpFields, TriggerFields } from './fields.tsx'
28
37
  import type { Translate } from './i18n.ts'
29
38
 
30
39
  export interface CardProps extends PluginConfigViewProps {
@@ -33,88 +42,95 @@ export interface CardProps extends PluginConfigViewProps {
33
42
  api: NamespaceSettingsApi
34
43
  }
35
44
 
36
- /** settings 命名空间的脱敏解析值(smtp.pass 被脱敏剥除)。 */
37
- export interface ConfigValue {
38
- enabled: boolean
39
- smtp: {
40
- host: string
41
- port: number
42
- secure: boolean
43
- user: string
44
- from: string
45
- }
46
- to: string[]
47
- triggers: {
48
- onComplete: boolean
49
- onError: boolean
50
- onAborted: boolean
51
- onQuestion: boolean
52
- onPlanReview: boolean
53
- }
54
- idleDebounceMs: number
55
- maxBodyChars: number
56
- dryRun: boolean
57
- }
58
-
59
- interface Draft {
60
- enabled: boolean
61
- host: string
62
- port: string
63
- secure: boolean
64
- user: string
65
- pass: string
66
- from: string
67
- toText: string
68
- triggers: ConfigValue['triggers']
69
- idleDebounceMs: string
70
- maxBodyChars: string
71
- dryRun: boolean
72
- }
73
-
74
- function draftFromValue(value: ConfigValue): Draft {
75
- return {
76
- enabled: value.enabled,
77
- host: value.smtp.host,
78
- port: String(value.smtp.port),
79
- secure: value.smtp.secure,
80
- user: value.smtp.user,
81
- pass: '',
82
- from: value.smtp.from,
83
- toText: value.to.join(', '),
84
- triggers: { ...value.triggers },
85
- idleDebounceMs: String(value.idleDebounceMs),
86
- maxBodyChars: String(value.maxBodyChars),
87
- dryRun: value.dryRun,
88
- }
89
- }
90
-
91
- function isPositiveInt(text: string): boolean {
92
- return /^[0-9]+$/.test(text) && Number(text) > 0
45
+ /**
46
+ * passConfigured 探测:scope 快照不含 secrets,经 describe 的 secrets 列表判断;
47
+ * snapshot 变化(保存后 revision 推进)时刻意重探。
48
+ */
49
+ function usePassConfigured(api: NamespaceSettingsApi, snapshot: unknown): boolean {
50
+ const [passConfigured, setPassConfigured] = useState(false)
51
+ // biome-ignore lint/correctness/useExhaustiveDependencies: snapshot 变化(保存后 revision 推进)时刻意重探 secrets 状态
52
+ useEffect(() => {
53
+ let alive = true
54
+ api
55
+ .describe()
56
+ .then((view) => {
57
+ if (!alive) return
58
+ const ns = view.namespaces.find((candidate) => candidate.ns === SETTINGS_NS)
59
+ setPassConfigured(
60
+ ns?.secrets.some((secret) => secret.path.join('.') === 'smtp.pass' && secret.set) ??
61
+ false,
62
+ )
63
+ })
64
+ .catch(() => {})
65
+ return () => {
66
+ alive = false
67
+ }
68
+ }, [api, snapshot])
69
+ return passConfigured
93
70
  }
94
71
 
95
- function toPatch(draft: Draft): Record<string, unknown> {
96
- return {
97
- enabled: draft.enabled,
98
- smtp: {
99
- host: draft.host.trim(),
100
- port: Number(draft.port),
101
- secure: draft.secure,
102
- user: draft.user.trim(),
103
- pass: draft.pass,
104
- from: draft.from.trim(),
72
+ /** 卡片动作:发送测试、放弃、保存(保存受无效字段与只读限制)。 */
73
+ function cardActions(options: {
74
+ t: Translate
75
+ dirty: boolean
76
+ invalid: boolean
77
+ saving: boolean
78
+ testing: boolean
79
+ disabled: boolean
80
+ onTest(): void
81
+ onDiscard(): void
82
+ onSave(): void
83
+ }): CardAction[] {
84
+ const { t } = options
85
+ return [
86
+ {
87
+ key: 'test',
88
+ label: t(options.testing ? 'testing' : 'test'),
89
+ disabled: options.testing,
90
+ onClick: options.onTest,
105
91
  },
106
- to: draft.toText
107
- .split(',')
108
- .map((s) => s.trim())
109
- .filter((s) => s.length > 0),
110
- triggers: { ...draft.triggers },
111
- idleDebounceMs: Number(draft.idleDebounceMs),
112
- maxBodyChars: Number(draft.maxBodyChars),
113
- dryRun: draft.dryRun,
114
- }
92
+ {
93
+ key: 'discard',
94
+ label: t('discard'),
95
+ disabled: !options.dirty || options.saving,
96
+ onClick: options.onDiscard,
97
+ },
98
+ {
99
+ key: 'save',
100
+ label: t(options.saving ? 'saving' : 'save'),
101
+ variant: 'primary',
102
+ disabled: !options.dirty || options.invalid || options.saving || options.disabled,
103
+ onClick: options.onSave,
104
+ },
105
+ ]
115
106
  }
116
107
 
117
- const TRIGGER_KEYS = ['onComplete', 'onError', 'onAborted', 'onQuestion', 'onPlanReview'] as const
108
+ /** 发送测试邮件:端点结果折算成状态行文案(不改草稿);finally 收尾由调用方给。 */
109
+ function sendTestMail(
110
+ t: Translate,
111
+ onStatus: (status: CardStatusState) => void,
112
+ onSettled: () => void,
113
+ ): void {
114
+ sendTest()
115
+ .then((result) => {
116
+ if (result.ok) {
117
+ onStatus({
118
+ kind: 'ok',
119
+ text: result.detail === 'dry-run' ? t('testDryRun') : t('testOk'),
120
+ })
121
+ return
122
+ }
123
+ const why = result.detail === 'incomplete' ? t('incomplete') : result.detail
124
+ onStatus({ kind: 'error', text: `${t('testFailed')}${why}` })
125
+ })
126
+ .catch((error: unknown) => {
127
+ onStatus({
128
+ kind: 'error',
129
+ text: `${t('testFailed')}${error instanceof Error ? error.message : ''}`,
130
+ })
131
+ })
132
+ .finally(onSettled)
133
+ }
118
134
 
119
135
  export function NotifyEmailCard(props: CardProps): ReactElement | string | null {
120
136
  const { t, scope, api } = props
@@ -123,13 +139,11 @@ export function NotifyEmailCard(props: CardProps): ReactElement | string | null
123
139
  () => scope.getSnapshot(),
124
140
  )
125
141
  const value = snapshot.value as ConfigValue | undefined
126
- // 0.1.6-alpha.2 插件页 page 视图为表单落地页,默认展开;旧槽位无 view,保持折叠。
127
- const [open, setOpen] = useState(props.view === 'page')
128
142
  const [draft, setDraft] = useState<Draft | null>(null)
129
- const [passConfigured, setPassConfigured] = useState(false)
130
143
  const [saving, setSaving] = useState(false)
131
144
  const [testing, setTesting] = useState(false)
132
145
  const [status, setStatus] = useState<CardStatusState>(IDLE_STATUS)
146
+ const passConfigured = usePassConfigured(api, snapshot)
133
147
 
134
148
  // 首次拿到解析值后播种草稿;后续 Host 更新不覆盖在途编辑(与官方 staged 表单一致)。
135
149
  useEffect(() => {
@@ -137,26 +151,6 @@ export function NotifyEmailCard(props: CardProps): ReactElement | string | null
137
151
  setDraft(draftFromValue(value))
138
152
  }, [value, draft])
139
153
 
140
- // passConfigured 探测:scope 快照不含 secrets,经 describe 的 secrets 列表判断。
141
- // biome-ignore lint/correctness/useExhaustiveDependencies: snapshot 变化(保存后 revision 推进)时刻意重探 secrets 状态
142
- useEffect(() => {
143
- let alive = true
144
- api
145
- .describe()
146
- .then((view) => {
147
- if (!alive) return
148
- const ns = view.namespaces.find((candidate) => candidate.ns === SETTINGS_NS)
149
- setPassConfigured(
150
- ns?.secrets.some((secret) => secret.path.join('.') === 'smtp.pass' && secret.set) ??
151
- false,
152
- )
153
- })
154
- .catch(() => {})
155
- return () => {
156
- alive = false
157
- }
158
- }, [api, snapshot])
159
-
160
154
  const dirty = useMemo(
161
155
  () =>
162
156
  value !== undefined &&
@@ -178,17 +172,13 @@ export function NotifyEmailCard(props: CardProps): ReactElement | string | null
178
172
  if (props.view === 'summary') return t('summaryLine')
179
173
 
180
174
  if (value === undefined || draft === null) {
181
- return (
182
- <li className="dne-card">
183
- <p className="dne-readOnly">{t('loading')}</p>
184
- </li>
185
- )
175
+ return <CardLoading prefix="dne" text={t('loading')} />
186
176
  }
187
177
  const edit = <K extends keyof Draft>(key: K, editValue: Draft[K]): void => {
188
178
  setDraft({ ...draft, [key]: editValue })
189
179
  setStatus(IDLE_STATUS)
190
180
  }
191
- const editTrigger = (key: (typeof TRIGGER_KEYS)[number], checked: boolean): void => {
181
+ const editTrigger = (key: TriggerKey, checked: boolean): void => {
192
182
  edit('triggers', { ...draft.triggers, [key]: checked })
193
183
  }
194
184
  const onSave = (): void => {
@@ -214,25 +204,7 @@ export function NotifyEmailCard(props: CardProps): ReactElement | string | null
214
204
  }
215
205
  const onTest = (): void => {
216
206
  setTesting(true)
217
- sendTest()
218
- .then((result) => {
219
- if (result.ok) {
220
- setStatus({
221
- kind: 'ok',
222
- text: result.detail === 'dry-run' ? t('testDryRun') : t('testOk'),
223
- })
224
- } else {
225
- const why = result.detail === 'incomplete' ? t('incomplete') : result.detail
226
- setStatus({ kind: 'error', text: `${t('testFailed')}${why}` })
227
- }
228
- })
229
- .catch((error: unknown) => {
230
- setStatus({
231
- kind: 'error',
232
- text: `${t('testFailed')}${error instanceof Error ? error.message : ''}`,
233
- })
234
- })
235
- .finally(() => setTesting(false))
207
+ sendTestMail(t, setStatus, () => setTesting(false))
236
208
  }
237
209
 
238
210
  const disabled = !snapshot.writable
@@ -241,37 +213,25 @@ export function NotifyEmailCard(props: CardProps): ReactElement | string | null
241
213
  prefix="dne"
242
214
  title={t('title')}
243
215
  description={t('description')}
244
- open={open}
245
- onToggle={setOpen}
246
216
  statusBadge={{ text: t(draft.enabled ? 'enabledOn' : 'enabledOff'), on: draft.enabled }}
247
217
  dirty={dirty}
248
218
  dirtyLabel={t('unsaved')}
249
219
  readOnlyNotice={disabled ? t('readOnly') : undefined}
250
220
  status={status}
251
- actions={[
252
- {
253
- key: 'test',
254
- label: t(testing ? 'testing' : 'test'),
255
- disabled: testing,
256
- onClick: onTest,
257
- },
258
- {
259
- key: 'discard',
260
- label: t('discard'),
261
- disabled: !dirty || saving,
262
- onClick: () => {
263
- setDraft(draftFromValue(value))
264
- setStatus(IDLE_STATUS)
265
- },
266
- },
267
- {
268
- key: 'save',
269
- label: t(saving ? 'saving' : 'save'),
270
- variant: 'primary',
271
- disabled: !dirty || invalid || saving || disabled,
272
- onClick: onSave,
221
+ actions={cardActions({
222
+ t,
223
+ dirty,
224
+ invalid,
225
+ saving,
226
+ testing,
227
+ disabled,
228
+ onTest,
229
+ onDiscard: () => {
230
+ setDraft(draftFromValue(value))
231
+ setStatus(IDLE_STATUS)
273
232
  },
274
- ]}
233
+ onSave,
234
+ })}
275
235
  >
276
236
  <CheckRow
277
237
  prefix="dne"
@@ -281,119 +241,19 @@ export function NotifyEmailCard(props: CardProps): ReactElement | string | null
281
241
  disabled={disabled}
282
242
  onEdit={(v) => edit('enabled', v)}
283
243
  />
284
- <TextField
285
- prefix="dne"
286
- id="dne-host"
287
- label={t('host')}
288
- hint={t('hostHint')}
289
- value={draft.host}
290
- disabled={disabled}
291
- onEdit={(v) => edit('host', v)}
292
- />
293
- <TextField
294
- prefix="dne"
295
- id="dne-port"
296
- label={t('port')}
297
- hint={t('portHint')}
298
- value={draft.port}
299
- numeric
300
- disabled={disabled}
301
- invalid={!isPositiveInt(draft.port)}
302
- invalidLabel={t('invalidNumber')}
303
- onEdit={(v) => edit('port', v)}
304
- />
305
- <CheckRow
306
- prefix="dne"
307
- id="dne-secure"
308
- label={t('secure')}
309
- checked={draft.secure}
310
- disabled={disabled}
311
- onEdit={(v) => edit('secure', v)}
312
- />
313
- <TextField
314
- prefix="dne"
315
- id="dne-user"
316
- label={t('user')}
317
- hint={t('userHint')}
318
- value={draft.user}
319
- disabled={disabled}
320
- onEdit={(v) => edit('user', v)}
321
- />
322
- <TextField
323
- prefix="dne"
324
- id="dne-pass"
325
- label={t('pass')}
326
- hint={t('passHint')}
327
- value={draft.pass}
328
- password
329
- disabled={disabled}
330
- badge={{
331
- text: passConfigured ? t('passSet') : t('passUnset'),
332
- set: passConfigured,
333
- }}
334
- onEdit={(v) => edit('pass', v)}
335
- />
336
- <TextField
337
- prefix="dne"
338
- id="dne-from"
339
- label={t('from')}
340
- hint={t('fromHint')}
341
- value={draft.from}
244
+ <SmtpFields
245
+ t={t}
246
+ draft={draft}
342
247
  disabled={disabled}
343
- onEdit={(v) => edit('from', v)}
248
+ passConfigured={passConfigured}
249
+ onEdit={edit}
344
250
  />
345
- <TextField
346
- prefix="dne"
347
- id="dne-to"
348
- label={t('to')}
349
- hint={t('toHint')}
350
- value={draft.toText}
351
- disabled={disabled}
352
- onEdit={(v) => edit('toText', v)}
353
- />
354
- <p className="dne-groupLabel">{t('triggerGroup')}</p>
355
- {TRIGGER_KEYS.map((key) => (
356
- <CheckRow
357
- prefix="dne"
358
- key={key}
359
- id={`dne-${key}`}
360
- label={t(key)}
361
- checked={draft.triggers[key]}
362
- disabled={disabled}
363
- onEdit={(v) => editTrigger(key, v)}
364
- />
365
- ))}
366
- <TextField
367
- prefix="dne"
368
- id="dne-debounce"
369
- label={t('idleDebounceMs')}
370
- hint={t('idleDebounceMsHint')}
371
- value={draft.idleDebounceMs}
372
- numeric
373
- disabled={disabled}
374
- invalid={!isPositiveInt(draft.idleDebounceMs)}
375
- invalidLabel={t('invalidNumber')}
376
- onEdit={(v) => edit('idleDebounceMs', v)}
377
- />
378
- <TextField
379
- prefix="dne"
380
- id="dne-maxchars"
381
- label={t('maxBodyChars')}
382
- hint={t('maxBodyCharsHint')}
383
- value={draft.maxBodyChars}
384
- numeric
385
- disabled={disabled}
386
- invalid={!isPositiveInt(draft.maxBodyChars) || Number(draft.maxBodyChars) < 200}
387
- invalidLabel={t('invalidNumber')}
388
- onEdit={(v) => edit('maxBodyChars', v)}
389
- />
390
- <CheckRow
391
- prefix="dne"
392
- id="dne-dryrun"
393
- label={t('dryRun')}
394
- checked={draft.dryRun}
251
+ <TriggerFields
252
+ t={t}
253
+ draft={draft}
395
254
  disabled={disabled}
396
- onEdit={(v) => edit('dryRun', v)}
255
+ onEdit={edit}
256
+ onTrigger={editTrigger}
397
257
  />
398
258
  </CardChrome>
399
259
  )
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * 浏览器半入口:注册 locale 字典 + 注册「邮件通知」卡片
3
- * (injectPluginConfigCard 三槽位:legacy settings.plugin.item 与 0.1.6-alpha.2
4
- * 独立插件页的 plugins.row.config / plugins.bundle.config)。
3
+ * (injectPluginConfigCard 两槽位:插件页 plugins.row.config /
4
+ * plugins.bundle.config)。
5
5
  * 构建产物为 window.__ModuleLoader__.load({id, factory}) 形式的 CJS factory
6
6
  * (包装见 tsdown.config.ts);样式沿用官方 data-plugin-css 约定,HMR 据此卸载。
7
7
  *
@@ -0,0 +1,99 @@
1
+ /**
2
+ * 卡片草稿模型与纯校验(无 React/JSX,供 node --test 直接导入):
3
+ * 脱敏解析值 → 草稿、草稿 → 提交形状、触发键表与正整数判定。
4
+ * @module notify-email/client/draft
5
+ */
6
+
7
+ /** settings 命名空间的脱敏解析值(smtp.pass 被脱敏剥除)。 */
8
+ export interface ConfigValue {
9
+ enabled: boolean
10
+ smtp: {
11
+ host: string
12
+ port: number
13
+ secure: boolean
14
+ user: string
15
+ from: string
16
+ }
17
+ to: string[]
18
+ triggers: {
19
+ onComplete: boolean
20
+ onError: boolean
21
+ onAborted: boolean
22
+ onQuestion: boolean
23
+ onPlanReview: boolean
24
+ }
25
+ idleDebounceMs: number
26
+ maxBodyChars: number
27
+ dryRun: boolean
28
+ }
29
+
30
+ /** 编辑草稿:数值字段以文本承载(保存时校验并折算)。 */
31
+ export interface Draft {
32
+ enabled: boolean
33
+ host: string
34
+ port: string
35
+ secure: boolean
36
+ user: string
37
+ pass: string
38
+ from: string
39
+ toText: string
40
+ triggers: ConfigValue['triggers']
41
+ idleDebounceMs: string
42
+ maxBodyChars: string
43
+ dryRun: boolean
44
+ }
45
+
46
+ /** 通知触发点(key 同时是 i18n 文案键与 settings 字段名)。 */
47
+ export const TRIGGER_KEYS = [
48
+ 'onComplete',
49
+ 'onError',
50
+ 'onAborted',
51
+ 'onQuestion',
52
+ 'onPlanReview',
53
+ ] as const
54
+
55
+ export type TriggerKey = (typeof TRIGGER_KEYS)[number]
56
+
57
+ export function isPositiveInt(text: string): boolean {
58
+ return /^[0-9]+$/.test(text) && Number(text) > 0
59
+ }
60
+
61
+ export function draftFromValue(value: ConfigValue): Draft {
62
+ return {
63
+ enabled: value.enabled,
64
+ host: value.smtp.host,
65
+ port: String(value.smtp.port),
66
+ secure: value.smtp.secure,
67
+ user: value.smtp.user,
68
+ pass: '',
69
+ from: value.smtp.from,
70
+ toText: value.to.join(', '),
71
+ triggers: { ...value.triggers },
72
+ idleDebounceMs: String(value.idleDebounceMs),
73
+ maxBodyChars: String(value.maxBodyChars),
74
+ dryRun: value.dryRun,
75
+ }
76
+ }
77
+
78
+ /** 草稿 → 提交形状(pass 为空表示「保持已存密钥」,由调用方剔除该键)。 */
79
+ export function toPatch(draft: Draft): Record<string, unknown> {
80
+ return {
81
+ enabled: draft.enabled,
82
+ smtp: {
83
+ host: draft.host.trim(),
84
+ port: Number(draft.port),
85
+ secure: draft.secure,
86
+ user: draft.user.trim(),
87
+ pass: draft.pass,
88
+ from: draft.from.trim(),
89
+ },
90
+ to: draft.toText
91
+ .split(',')
92
+ .map((s) => s.trim())
93
+ .filter((s) => s.length > 0),
94
+ triggers: { ...draft.triggers },
95
+ idleDebounceMs: Number(draft.idleDebounceMs),
96
+ maxBodyChars: Number(draft.maxBodyChars),
97
+ dryRun: draft.dryRun,
98
+ }
99
+ }