@dsh-plus/notify-email 0.1.36 → 0.1.37

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.37",
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.31"
36
36
  },
37
37
  "peerDependencies": {
38
38
  "@deepseek-ai/cordis": "^4.0.4",
@@ -13,18 +13,29 @@
13
13
  */
14
14
 
15
15
  import {
16
+ type CardAction,
16
17
  CardChrome,
18
+ CardLoading,
17
19
  type CardStatusState,
18
20
  CheckRow,
21
+ cardVariantFor,
19
22
  IDLE_STATUS,
20
23
  type NamespaceSettingsApi,
21
24
  type PluginConfigViewProps,
22
25
  type Scope,
23
- TextField,
24
26
  } from '@dsh-plus/shared/client'
25
27
  import { type ReactElement, useEffect, useMemo, useState, useSyncExternalStore } from 'react'
26
28
  import { SETTINGS_NS } from '../ns.ts'
27
29
  import { sendTest } from './api.ts'
30
+ import {
31
+ type ConfigValue,
32
+ type Draft,
33
+ draftFromValue,
34
+ isPositiveInt,
35
+ type TriggerKey,
36
+ toPatch,
37
+ } from './draft.ts'
38
+ import { SmtpFields, TriggerFields } from './fields.tsx'
28
39
  import type { Translate } from './i18n.ts'
29
40
 
30
41
  export interface CardProps extends PluginConfigViewProps {
@@ -33,88 +44,95 @@ export interface CardProps extends PluginConfigViewProps {
33
44
  api: NamespaceSettingsApi
34
45
  }
35
46
 
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
47
+ /**
48
+ * passConfigured 探测:scope 快照不含 secrets,经 describe 的 secrets 列表判断;
49
+ * snapshot 变化(保存后 revision 推进)时刻意重探。
50
+ */
51
+ function usePassConfigured(api: NamespaceSettingsApi, snapshot: unknown): boolean {
52
+ const [passConfigured, setPassConfigured] = useState(false)
53
+ // biome-ignore lint/correctness/useExhaustiveDependencies: snapshot 变化(保存后 revision 推进)时刻意重探 secrets 状态
54
+ useEffect(() => {
55
+ let alive = true
56
+ api
57
+ .describe()
58
+ .then((view) => {
59
+ if (!alive) return
60
+ const ns = view.namespaces.find((candidate) => candidate.ns === SETTINGS_NS)
61
+ setPassConfigured(
62
+ ns?.secrets.some((secret) => secret.path.join('.') === 'smtp.pass' && secret.set) ??
63
+ false,
64
+ )
65
+ })
66
+ .catch(() => {})
67
+ return () => {
68
+ alive = false
69
+ }
70
+ }, [api, snapshot])
71
+ return passConfigured
93
72
  }
94
73
 
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(),
74
+ /** 卡片动作:发送测试、放弃、保存(保存受无效字段与只读限制)。 */
75
+ function cardActions(options: {
76
+ t: Translate
77
+ dirty: boolean
78
+ invalid: boolean
79
+ saving: boolean
80
+ testing: boolean
81
+ disabled: boolean
82
+ onTest(): void
83
+ onDiscard(): void
84
+ onSave(): void
85
+ }): CardAction[] {
86
+ const { t } = options
87
+ return [
88
+ {
89
+ key: 'test',
90
+ label: t(options.testing ? 'testing' : 'test'),
91
+ disabled: options.testing,
92
+ onClick: options.onTest,
105
93
  },
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
- }
94
+ {
95
+ key: 'discard',
96
+ label: t('discard'),
97
+ disabled: !options.dirty || options.saving,
98
+ onClick: options.onDiscard,
99
+ },
100
+ {
101
+ key: 'save',
102
+ label: t(options.saving ? 'saving' : 'save'),
103
+ variant: 'primary',
104
+ disabled: !options.dirty || options.invalid || options.saving || options.disabled,
105
+ onClick: options.onSave,
106
+ },
107
+ ]
115
108
  }
116
109
 
117
- const TRIGGER_KEYS = ['onComplete', 'onError', 'onAborted', 'onQuestion', 'onPlanReview'] as const
110
+ /** 发送测试邮件:端点结果折算成状态行文案(不改草稿);finally 收尾由调用方给。 */
111
+ function sendTestMail(
112
+ t: Translate,
113
+ onStatus: (status: CardStatusState) => void,
114
+ onSettled: () => void,
115
+ ): void {
116
+ sendTest()
117
+ .then((result) => {
118
+ if (result.ok) {
119
+ onStatus({
120
+ kind: 'ok',
121
+ text: result.detail === 'dry-run' ? t('testDryRun') : t('testOk'),
122
+ })
123
+ return
124
+ }
125
+ const why = result.detail === 'incomplete' ? t('incomplete') : result.detail
126
+ onStatus({ kind: 'error', text: `${t('testFailed')}${why}` })
127
+ })
128
+ .catch((error: unknown) => {
129
+ onStatus({
130
+ kind: 'error',
131
+ text: `${t('testFailed')}${error instanceof Error ? error.message : ''}`,
132
+ })
133
+ })
134
+ .finally(onSettled)
135
+ }
118
136
 
119
137
  export function NotifyEmailCard(props: CardProps): ReactElement | string | null {
120
138
  const { t, scope, api } = props
@@ -126,10 +144,10 @@ export function NotifyEmailCard(props: CardProps): ReactElement | string | null
126
144
  // 0.1.6-alpha.2 插件页 page 视图为表单落地页,默认展开;旧槽位无 view,保持折叠。
127
145
  const [open, setOpen] = useState(props.view === 'page')
128
146
  const [draft, setDraft] = useState<Draft | null>(null)
129
- const [passConfigured, setPassConfigured] = useState(false)
130
147
  const [saving, setSaving] = useState(false)
131
148
  const [testing, setTesting] = useState(false)
132
149
  const [status, setStatus] = useState<CardStatusState>(IDLE_STATUS)
150
+ const passConfigured = usePassConfigured(api, snapshot)
133
151
 
134
152
  // 首次拿到解析值后播种草稿;后续 Host 更新不覆盖在途编辑(与官方 staged 表单一致)。
135
153
  useEffect(() => {
@@ -137,26 +155,6 @@ export function NotifyEmailCard(props: CardProps): ReactElement | string | null
137
155
  setDraft(draftFromValue(value))
138
156
  }, [value, draft])
139
157
 
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
158
  const dirty = useMemo(
161
159
  () =>
162
160
  value !== undefined &&
@@ -177,18 +175,17 @@ export function NotifyEmailCard(props: CardProps): ReactElement | string | null
177
175
  // 插件页 summary 视图只出一行简介(hooks 已全部落定,可安全提前返回)。
178
176
  if (props.view === 'summary') return t('summaryLine')
179
177
 
178
+ // 插件页 page 视图内嵌宿主页面容器(已带页面级内边距与标题),用无边框分节。
179
+ const variant = cardVariantFor(props.view)
180
+
180
181
  if (value === undefined || draft === null) {
181
- return (
182
- <li className="dne-card">
183
- <p className="dne-readOnly">{t('loading')}</p>
184
- </li>
185
- )
182
+ return <CardLoading prefix="dne" variant={variant} text={t('loading')} />
186
183
  }
187
184
  const edit = <K extends keyof Draft>(key: K, editValue: Draft[K]): void => {
188
185
  setDraft({ ...draft, [key]: editValue })
189
186
  setStatus(IDLE_STATUS)
190
187
  }
191
- const editTrigger = (key: (typeof TRIGGER_KEYS)[number], checked: boolean): void => {
188
+ const editTrigger = (key: TriggerKey, checked: boolean): void => {
192
189
  edit('triggers', { ...draft.triggers, [key]: checked })
193
190
  }
194
191
  const onSave = (): void => {
@@ -214,25 +211,7 @@ export function NotifyEmailCard(props: CardProps): ReactElement | string | null
214
211
  }
215
212
  const onTest = (): void => {
216
213
  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))
214
+ sendTestMail(t, setStatus, () => setTesting(false))
236
215
  }
237
216
 
238
217
  const disabled = !snapshot.writable
@@ -243,35 +222,26 @@ export function NotifyEmailCard(props: CardProps): ReactElement | string | null
243
222
  description={t('description')}
244
223
  open={open}
245
224
  onToggle={setOpen}
225
+ variant={variant}
246
226
  statusBadge={{ text: t(draft.enabled ? 'enabledOn' : 'enabledOff'), on: draft.enabled }}
247
227
  dirty={dirty}
248
228
  dirtyLabel={t('unsaved')}
249
229
  readOnlyNotice={disabled ? t('readOnly') : undefined}
250
230
  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,
231
+ actions={cardActions({
232
+ t,
233
+ dirty,
234
+ invalid,
235
+ saving,
236
+ testing,
237
+ disabled,
238
+ onTest,
239
+ onDiscard: () => {
240
+ setDraft(draftFromValue(value))
241
+ setStatus(IDLE_STATUS)
273
242
  },
274
- ]}
243
+ onSave,
244
+ })}
275
245
  >
276
246
  <CheckRow
277
247
  prefix="dne"
@@ -281,119 +251,19 @@ export function NotifyEmailCard(props: CardProps): ReactElement | string | null
281
251
  disabled={disabled}
282
252
  onEdit={(v) => edit('enabled', v)}
283
253
  />
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}
254
+ <SmtpFields
255
+ t={t}
256
+ draft={draft}
342
257
  disabled={disabled}
343
- onEdit={(v) => edit('from', v)}
258
+ passConfigured={passConfigured}
259
+ onEdit={edit}
344
260
  />
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}
261
+ <TriggerFields
262
+ t={t}
263
+ draft={draft}
395
264
  disabled={disabled}
396
- onEdit={(v) => edit('dryRun', v)}
265
+ onEdit={edit}
266
+ onTrigger={editTrigger}
397
267
  />
398
268
  </CardChrome>
399
269
  )
@@ -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
+ }