@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 +1 -1
- package/lib/index.js +41 -41
- package/package.json +1 -1
- package/src/client/card.tsx +169 -52
- package/src/client/client.ts +20 -8
- package/src/client/scope.ts +25 -3
- package/src/config.ts +15 -3
- package/src/index.ts +8 -3
- package/src/mailer.ts +2 -2
- package/src/service.ts +23 -17
- package/src/triggers/builtin.ts +5 -2
- package/src/watchers/turn-end.ts +4 -7
package/lib/index.d.ts
CHANGED
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,
|
|
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
package/src/client/card.tsx
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* 保存经 settings.update 深合并(空 pass 剔除 = 保持不变)。
|
|
8
8
|
* @module notify-email/client/card
|
|
9
9
|
*/
|
|
10
|
-
import { useEffect, useMemo,
|
|
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
|
|
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
|
|
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) ??
|
|
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 &&
|
|
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) ||
|
|
157
|
-
!isPositiveInt(draft.
|
|
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
|
|
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
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
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({
|
|
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({
|
|
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 ?
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
<
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
<TextField
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
<TextField
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
257
|
-
|
|
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
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
-
|
|
269
|
-
|
|
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
|
|
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
|
|
277
|
-
|
|
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
|
|
281
|
-
|
|
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
|
|
285
|
-
|
|
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>
|
package/src/client/client.ts
CHANGED
|
@@ -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 = {
|
|
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({
|
|
90
|
+
publish({
|
|
91
|
+
status: 'unavailable',
|
|
92
|
+
value: undefined,
|
|
93
|
+
revision: undefined,
|
|
94
|
+
writable,
|
|
95
|
+
})
|
|
86
96
|
return
|
|
87
97
|
}
|
|
88
|
-
publish({
|
|
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', () =>
|
package/src/client/scope.ts
CHANGED
|
@@ -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<{
|
|
35
|
-
|
|
36
|
-
|
|
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
|
-
|
|
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({
|
|
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,
|
|
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 {
|
|
9
|
-
import {
|
|
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
|
-
|
|
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
|
-
{
|
|
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: {
|
|
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
|
-
|
|
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
|
|
113
|
-
|
|
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
|
|
121
|
-
|
|
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
|
}
|
package/src/triggers/builtin.ts
CHANGED
|
@@ -5,8 +5,11 @@
|
|
|
5
5
|
*/
|
|
6
6
|
import type { NotifyEmailConfig } from '../config.ts'
|
|
7
7
|
import {
|
|
8
|
-
renderAbortedNotice,
|
|
9
|
-
|
|
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
|
|
package/src/watchers/turn-end.ts
CHANGED
|
@@ -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
|
-
|
|
97
|
-
|
|
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) => {
|