@dsh-plus/notify-email 0.1.35 → 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/lib/client.js +375 -216
- package/package.json +2 -2
- package/src/client/card.tsx +127 -257
- package/src/client/draft.ts +99 -0
- package/src/client/fields.tsx +153 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@dsh-plus/notify-email",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
35
|
+
"@dsh-plus/shared": "0.1.31"
|
|
36
36
|
},
|
|
37
37
|
"peerDependencies": {
|
|
38
38
|
"@deepseek-ai/cordis": "^4.0.4",
|
package/src/client/card.tsx
CHANGED
|
@@ -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
|
-
/**
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
.
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
-
<
|
|
285
|
-
|
|
286
|
-
|
|
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
|
-
|
|
258
|
+
passConfigured={passConfigured}
|
|
259
|
+
onEdit={edit}
|
|
344
260
|
/>
|
|
345
|
-
<
|
|
346
|
-
|
|
347
|
-
|
|
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={
|
|
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
|
+
}
|