@better-forge/notify 0.0.0-stage → 0.1.0
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/LICENSE +21 -0
- package/README.md +11 -3
- package/dist/alertPolicy.d.ts +41 -0
- package/dist/alertPolicy.d.ts.map +1 -0
- package/dist/alertPolicy.js +137 -0
- package/dist/alertPolicy.js.map +1 -0
- package/dist/channelInput.d.ts +34 -0
- package/dist/channelInput.d.ts.map +1 -0
- package/dist/channelInput.js +156 -0
- package/dist/channelInput.js.map +1 -0
- package/dist/channelSecrets.d.ts +8 -0
- package/dist/channelSecrets.d.ts.map +1 -0
- package/dist/channelSecrets.js +17 -0
- package/dist/channelSecrets.js.map +1 -0
- package/dist/engine.d.ts +68 -0
- package/dist/engine.d.ts.map +1 -0
- package/dist/engine.js +314 -0
- package/dist/engine.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/migrations.d.ts +907 -0
- package/dist/migrations.d.ts.map +1 -0
- package/dist/migrations.js +86 -0
- package/dist/migrations.js.map +1 -0
- package/dist/notice.d.ts +55 -0
- package/dist/notice.d.ts.map +1 -0
- package/dist/notice.js +25 -0
- package/dist/notice.js.map +1 -0
- package/dist/routes.d.ts +74 -0
- package/dist/routes.d.ts.map +1 -0
- package/dist/routes.js +302 -0
- package/dist/routes.js.map +1 -0
- package/dist/tables.d.ts +919 -0
- package/dist/tables.d.ts.map +1 -0
- package/dist/tables.js +68 -0
- package/dist/tables.js.map +1 -0
- package/dist/template.d.ts +35 -0
- package/dist/template.d.ts.map +1 -0
- package/dist/template.js +72 -0
- package/dist/template.js.map +1 -0
- package/dist/transports.d.ts +50 -0
- package/dist/transports.d.ts.map +1 -0
- package/dist/transports.js +274 -0
- package/dist/transports.js.map +1 -0
- package/dist/worker.d.ts +25 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +38 -0
- package/dist/worker.js.map +1 -0
- package/package.json +59 -6
- package/src/alertPolicy.ts +149 -0
- package/src/channelInput.ts +153 -0
- package/src/channelSecrets.ts +20 -0
- package/src/engine.ts +423 -0
- package/src/index.ts +16 -0
- package/src/migrations.ts +88 -0
- package/src/notice.ts +68 -0
- package/src/routes.ts +362 -0
- package/src/tables.ts +96 -0
- package/src/template.ts +76 -0
- package/src/transports.ts +351 -0
- package/src/worker.ts +58 -0
package/src/engine.ts
ADDED
|
@@ -0,0 +1,423 @@
|
|
|
1
|
+
import { and, asc, desc, eq, gt, gte, inArray, isNotNull, like, lt, lte, ne, sql } from 'drizzle-orm'
|
|
2
|
+
import type { NotificationSuppressionReason, VaultRef } from '@better-forge/contracts'
|
|
3
|
+
import type { SqliteDatabase } from '@better-forge/db'
|
|
4
|
+
import { createAlertPolicy, isWithinQuietHours, quietHoursEndAt, type AlertPolicy } from './alertPolicy.js'
|
|
5
|
+
import type { NotifyTables } from './tables.js'
|
|
6
|
+
import { MAX_DELIVERY_ATTEMPTS, RETRY_DELAYS_MS } from './template.js'
|
|
7
|
+
|
|
8
|
+
export type ChannelRow = NotifyTables['notificationChannels']['$inferSelect']
|
|
9
|
+
export type DeliveryRow = NotifyTables['notificationDeliveries']['$inferSelect']
|
|
10
|
+
|
|
11
|
+
/** What a delivery is about. A delivery without a source (a test, a digest) has none. */
|
|
12
|
+
export interface DeliveryDetails {
|
|
13
|
+
sourceId: number | null
|
|
14
|
+
sourceName: string
|
|
15
|
+
eventType: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface NotifyEngineOptions {
|
|
19
|
+
db: SqliteDatabase
|
|
20
|
+
tables: NotifyTables
|
|
21
|
+
/** Stored name of the grouping threshold in a channel's alert policy; see `createAlertPolicy`. */
|
|
22
|
+
minSourcesKey: string
|
|
23
|
+
/** The event types the application raises that alert hygiene treats specially. */
|
|
24
|
+
events: {
|
|
25
|
+
/** Rate-capped per source; the only event a quiet-hours or throttle rule can drop. */
|
|
26
|
+
alert: string
|
|
27
|
+
/** Closes an alert. Together with `alert` it tells whether a source still has an alert open. */
|
|
28
|
+
recovery: string
|
|
29
|
+
/** Never folded into a digest. */
|
|
30
|
+
ungrouped: string
|
|
31
|
+
}
|
|
32
|
+
/** Sends one delivery's variables through the channel. Throws on failure. */
|
|
33
|
+
deliver: (channel: ChannelRow, vars: Record<string, string>) => Promise<void>
|
|
34
|
+
/** The notification that stands in for several single-source events: its variables and the name it is recorded under. */
|
|
35
|
+
digest: (rows: DeliveryRow[], now: number) => { vars: Record<string, string>; sourceName: string }
|
|
36
|
+
/** Resolves a vault reference to its secret fields (used for SMTP credentials). */
|
|
37
|
+
resolveSecret: (ref: VaultRef) => Promise<Record<string, string>>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface SmtpMessage {
|
|
41
|
+
to: string
|
|
42
|
+
subject: string
|
|
43
|
+
text: string
|
|
44
|
+
/** Sent alongside `text` as multipart/alternative; clients that cannot render HTML show the text. */
|
|
45
|
+
html?: string
|
|
46
|
+
headers?: Record<string, string>
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type DeliveryVariant =
|
|
50
|
+
| { status: 'pending'; releaseAt?: number; groupKey?: string | null }
|
|
51
|
+
| { status: 'suppressed'; reason: NotificationSuppressionReason }
|
|
52
|
+
|
|
53
|
+
interface DeliveryPlan {
|
|
54
|
+
/** When the delivery may first be attempted. */
|
|
55
|
+
releaseAt: number
|
|
56
|
+
/** Digest window this delivery belongs to, or null when it stands alone. */
|
|
57
|
+
groupKey: string | null
|
|
58
|
+
suppressionReason: NotificationSuppressionReason | null
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Delivery history older than this is purged. */
|
|
62
|
+
const DELIVERY_RETENTION_MS = 180 * 24 * 60 * 60 * 1000
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* The delivery engine: records every notification, applies the channel's alert hygiene, sends with retries, folds
|
|
66
|
+
* bursts into digests and sends SMTP mail. Locks and in-flight sets live in the returned closure, so two engines
|
|
67
|
+
* never share state.
|
|
68
|
+
*/
|
|
69
|
+
export function createNotifyEngine(options: NotifyEngineOptions) {
|
|
70
|
+
const { db, deliver, resolveSecret } = options
|
|
71
|
+
const { notificationChannels, notificationDeliveries, notificationDeliveryAttempts, smtpSettings } = options.tables
|
|
72
|
+
const { parseAlertPolicy } = createAlertPolicy({ minSourcesKey: options.minSourcesKey })
|
|
73
|
+
const activeDeliveries = new Set<number>()
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Writes one delivery row. A pending row is picked up for sending at `releaseAt`; a suppressed row
|
|
77
|
+
* records an event that alert hygiene dropped, so the history still shows what was not sent.
|
|
78
|
+
*/
|
|
79
|
+
async function insertDelivery(
|
|
80
|
+
channel: ChannelRow,
|
|
81
|
+
vars: Record<string, string>,
|
|
82
|
+
details: DeliveryDetails,
|
|
83
|
+
variant: DeliveryVariant,
|
|
84
|
+
): Promise<number> {
|
|
85
|
+
const now = Date.now()
|
|
86
|
+
const [delivery] = await db.insert(notificationDeliveries).values({
|
|
87
|
+
channelId: channel.id,
|
|
88
|
+
channelName: channel.name,
|
|
89
|
+
channelType: channel.type,
|
|
90
|
+
sourceId: details.sourceId,
|
|
91
|
+
sourceName: details.sourceName,
|
|
92
|
+
eventType: details.eventType,
|
|
93
|
+
status: variant.status,
|
|
94
|
+
targetStatus: vars['status'] ?? 'unknown',
|
|
95
|
+
previousStatus: vars['previous_status'] ?? 'unknown',
|
|
96
|
+
variables: JSON.stringify(vars),
|
|
97
|
+
attemptCount: 0,
|
|
98
|
+
maxAttempts: MAX_DELIVERY_ATTEMPTS,
|
|
99
|
+
...(variant.status === 'pending'
|
|
100
|
+
? { nextAttemptAt: variant.releaseAt ?? now, groupKey: variant.groupKey ?? null }
|
|
101
|
+
: { nextAttemptAt: null, suppressionReason: variant.reason }),
|
|
102
|
+
createdAt: now,
|
|
103
|
+
updatedAt: now,
|
|
104
|
+
}).returning({ id: notificationDeliveries.id })
|
|
105
|
+
return delivery!.id
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
async function attemptDelivery(deliveryId: number, now = Date.now()): Promise<void> {
|
|
109
|
+
if (activeDeliveries.has(deliveryId)) return
|
|
110
|
+
activeDeliveries.add(deliveryId)
|
|
111
|
+
try {
|
|
112
|
+
const delivery = (await db.select().from(notificationDeliveries).where(eq(notificationDeliveries.id, deliveryId)))[0]
|
|
113
|
+
if (!delivery || delivery.status === 'delivered' || delivery.attemptCount >= delivery.maxAttempts) return
|
|
114
|
+
const channel = (await db.select().from(notificationChannels).where(eq(notificationChannels.id, delivery.channelId)))[0]
|
|
115
|
+
const attemptNumber = delivery.attemptCount + 1
|
|
116
|
+
const startedAt = Date.now()
|
|
117
|
+
try {
|
|
118
|
+
if (!channel) throw new Error('Notification channel no longer exists')
|
|
119
|
+
if (channel.enabled !== 1) throw new Error('Notification channel is disabled')
|
|
120
|
+
await deliver(channel, JSON.parse(delivery.variables) as Record<string, string>)
|
|
121
|
+
const completedAt = Date.now()
|
|
122
|
+
await db.insert(notificationDeliveryAttempts).values({ deliveryId, attemptNumber, status: 'delivered', error: null, startedAt, completedAt })
|
|
123
|
+
await db.update(notificationDeliveries).set({
|
|
124
|
+
status: 'delivered', attemptCount: attemptNumber, nextAttemptAt: null,
|
|
125
|
+
lastAttemptAt: completedAt, deliveredAt: completedAt, lastError: null, updatedAt: completedAt,
|
|
126
|
+
}).where(eq(notificationDeliveries.id, deliveryId))
|
|
127
|
+
} catch (error) {
|
|
128
|
+
const completedAt = Date.now()
|
|
129
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
130
|
+
const exhausted = attemptNumber >= delivery.maxAttempts
|
|
131
|
+
const delay = RETRY_DELAYS_MS[Math.min(attemptNumber - 1, RETRY_DELAYS_MS.length - 1)]!
|
|
132
|
+
await db.insert(notificationDeliveryAttempts).values({ deliveryId, attemptNumber, status: 'failed', error: message, startedAt, completedAt })
|
|
133
|
+
await db.update(notificationDeliveries).set({
|
|
134
|
+
status: exhausted ? 'failed' : 'pending', attemptCount: attemptNumber,
|
|
135
|
+
nextAttemptAt: exhausted ? null : now + delay, lastAttemptAt: completedAt,
|
|
136
|
+
lastError: message, updatedAt: completedAt,
|
|
137
|
+
}).where(eq(notificationDeliveries.id, deliveryId))
|
|
138
|
+
console.error(`[notifier] Delivery ${deliveryId}, attempt ${attemptNumber} failed: ${message}`)
|
|
139
|
+
}
|
|
140
|
+
} finally { activeDeliveries.delete(deliveryId) }
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Alert hygiene: rate caps, quiet hours and digest windows ───────────────
|
|
144
|
+
|
|
145
|
+
/** Alerts already sent or queued for this source on this channel inside the throttle window. */
|
|
146
|
+
async function countRecentAlerts(channelId: number, sourceId: number, windowStart: number): Promise<number> {
|
|
147
|
+
const [row] = await db.select({ count: sql<number>`count(*)` }).from(notificationDeliveries).where(and(
|
|
148
|
+
eq(notificationDeliveries.channelId, channelId),
|
|
149
|
+
eq(notificationDeliveries.sourceId, sourceId),
|
|
150
|
+
eq(notificationDeliveries.eventType, options.events.alert),
|
|
151
|
+
ne(notificationDeliveries.status, 'suppressed'),
|
|
152
|
+
gte(notificationDeliveries.createdAt, windowStart),
|
|
153
|
+
))
|
|
154
|
+
return row?.count ?? 0
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/** The digest window this channel already has open, so a burst lands in a single group. */
|
|
158
|
+
async function findOpenGroup(channelId: number, eventType: string, now: number): Promise<{ groupKey: string; releaseAt: number } | null> {
|
|
159
|
+
const [row] = await db.select({ groupKey: notificationDeliveries.groupKey, releaseAt: notificationDeliveries.nextAttemptAt })
|
|
160
|
+
.from(notificationDeliveries)
|
|
161
|
+
.where(and(
|
|
162
|
+
eq(notificationDeliveries.channelId, channelId),
|
|
163
|
+
eq(notificationDeliveries.status, 'pending'),
|
|
164
|
+
like(notificationDeliveries.groupKey, `ch${channelId}:${eventType}:w%`),
|
|
165
|
+
gt(notificationDeliveries.nextAttemptAt, now),
|
|
166
|
+
))
|
|
167
|
+
.orderBy(asc(notificationDeliveries.nextAttemptAt))
|
|
168
|
+
.limit(1)
|
|
169
|
+
return row?.groupKey && row.releaseAt !== null ? { groupKey: row.groupKey, releaseAt: row.releaseAt } : null
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* Applies a channel's alert hygiene to one event. Order matters: the rate cap runs first so a
|
|
174
|
+
* throttled alert never holds a digest window open, then quiet hours decide whether the event is
|
|
175
|
+
* dropped or held, then grouping decides which digest it joins.
|
|
176
|
+
*/
|
|
177
|
+
async function planDelivery(
|
|
178
|
+
channel: ChannelRow,
|
|
179
|
+
policy: AlertPolicy,
|
|
180
|
+
details: DeliveryDetails,
|
|
181
|
+
now: number,
|
|
182
|
+
): Promise<DeliveryPlan> {
|
|
183
|
+
// Recoveries are never throttled — the all-clear must always get through.
|
|
184
|
+
if (policy.throttle.enabled && details.eventType === options.events.alert && details.sourceId !== null) {
|
|
185
|
+
const windowStart = now - policy.throttle.windowMinutes * 60_000
|
|
186
|
+
const recent = await countRecentAlerts(channel.id, details.sourceId, windowStart)
|
|
187
|
+
if (recent >= policy.throttle.maxAlerts) return { releaseAt: now, groupKey: null, suppressionReason: 'throttled' }
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
let releaseAt = now
|
|
191
|
+
let quietUntil: number | null = null
|
|
192
|
+
if (isWithinQuietHours(policy.quietHours, now)) {
|
|
193
|
+
if (policy.quietHours.mode === 'suppress') return { releaseAt: now, groupKey: null, suppressionReason: 'quiet-hours' }
|
|
194
|
+
quietUntil = quietHoursEndAt(policy.quietHours, now)
|
|
195
|
+
releaseAt = Math.max(releaseAt, quietUntil)
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let groupKey: string | null = null
|
|
199
|
+
if (policy.grouping.enabled && details.eventType !== options.events.ungrouped) {
|
|
200
|
+
if (quietUntil !== null) {
|
|
201
|
+
// Everything held for the same quiet window is released at once — digest it as one message.
|
|
202
|
+
groupKey = `ch${channel.id}:${details.eventType}:q${quietUntil}`
|
|
203
|
+
} else {
|
|
204
|
+
const open = await findOpenGroup(channel.id, details.eventType, now)
|
|
205
|
+
if (open) {
|
|
206
|
+
groupKey = open.groupKey
|
|
207
|
+
releaseAt = Math.max(releaseAt, open.releaseAt)
|
|
208
|
+
} else {
|
|
209
|
+
groupKey = `ch${channel.id}:${details.eventType}:w${now}`
|
|
210
|
+
releaseAt = Math.max(releaseAt, now + policy.grouping.windowSeconds * 1000)
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
return { releaseAt, groupKey, suppressionReason: null }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Planning reads a channel's recent deliveries and then writes one. Two events for the same
|
|
220
|
+
* channel must not interleave across those awaits, or a burst — precisely what grouping exists
|
|
221
|
+
* for — would open one digest window per source, and a rate cap would be counted from a stale
|
|
222
|
+
* total. Sending happens outside the lock so a slow endpoint never stalls the queue.
|
|
223
|
+
*/
|
|
224
|
+
const channelLocks = new Map<number, Promise<unknown>>()
|
|
225
|
+
|
|
226
|
+
function withChannelLock<T>(channelId: number, work: () => Promise<T>): Promise<T> {
|
|
227
|
+
const previous = channelLocks.get(channelId) ?? Promise.resolve()
|
|
228
|
+
const next = previous.then(work, work)
|
|
229
|
+
channelLocks.set(channelId, next.catch(() => undefined))
|
|
230
|
+
return next
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Collapses every due digest window into a single notification. A window that did not reach
|
|
235
|
+
* `minSources` distinct sources is simply released as ordinary individual deliveries, so
|
|
236
|
+
* grouping costs at most `windowSeconds` of latency and never swallows a lone alert.
|
|
237
|
+
*/
|
|
238
|
+
let flushChain: Promise<unknown> = Promise.resolve()
|
|
239
|
+
|
|
240
|
+
function flushGroups(now = Date.now()): Promise<number> {
|
|
241
|
+
// The retry worker fires every 30s without awaiting the previous pass; overlapping flushes
|
|
242
|
+
// would each turn the same window into its own digest.
|
|
243
|
+
const run = () => flushDueGroups(now)
|
|
244
|
+
const next = flushChain.then(run, run)
|
|
245
|
+
flushChain = next.catch(() => undefined)
|
|
246
|
+
return next
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function flushDueGroups(now: number): Promise<number> {
|
|
250
|
+
const pending = await db.select().from(notificationDeliveries).where(and(
|
|
251
|
+
eq(notificationDeliveries.status, 'pending'),
|
|
252
|
+
isNotNull(notificationDeliveries.groupKey),
|
|
253
|
+
lte(notificationDeliveries.nextAttemptAt, now),
|
|
254
|
+
)).orderBy(asc(notificationDeliveries.createdAt))
|
|
255
|
+
if (pending.length === 0) return 0
|
|
256
|
+
|
|
257
|
+
const groups = new Map<string, DeliveryRow[]>()
|
|
258
|
+
for (const row of pending) {
|
|
259
|
+
const bucket = groups.get(row.groupKey!)
|
|
260
|
+
if (bucket) bucket.push(row)
|
|
261
|
+
else groups.set(row.groupKey!, [row])
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
let digests = 0
|
|
265
|
+
for (const rows of groups.values()) {
|
|
266
|
+
const ids = rows.map((row) => row.id)
|
|
267
|
+
const channel = (await db.select().from(notificationChannels).where(eq(notificationChannels.id, rows[0]!.channelId)))[0]
|
|
268
|
+
const policy = channel ? parseAlertPolicy(channel.alertPolicy) : null
|
|
269
|
+
const distinctSources = new Set(rows.map((row) => row.sourceId ?? -row.id)).size
|
|
270
|
+
|
|
271
|
+
if (!channel || !policy?.grouping.enabled || distinctSources < policy.grouping.minSources) {
|
|
272
|
+
// Not a burst — or grouping was switched off mid-window. Send them individually.
|
|
273
|
+
await db.update(notificationDeliveries).set({ groupKey: null, updatedAt: now })
|
|
274
|
+
.where(inArray(notificationDeliveries.id, ids))
|
|
275
|
+
continue
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const { vars, sourceName } = options.digest(rows, now)
|
|
279
|
+
// Retire the members first: a crash between the two writes must not let both forms go out.
|
|
280
|
+
await db.update(notificationDeliveries).set({
|
|
281
|
+
status: 'suppressed', suppressionReason: 'grouped', nextAttemptAt: null, updatedAt: now,
|
|
282
|
+
}).where(inArray(notificationDeliveries.id, ids))
|
|
283
|
+
await insertDelivery(channel, vars, {
|
|
284
|
+
sourceId: null,
|
|
285
|
+
sourceName,
|
|
286
|
+
eventType: rows[0]!.eventType,
|
|
287
|
+
}, { status: 'pending', releaseAt: now })
|
|
288
|
+
digests++
|
|
289
|
+
}
|
|
290
|
+
return digests
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
async function processDue(now = Date.now()): Promise<number> {
|
|
294
|
+
await flushGroups(now)
|
|
295
|
+
const due = await db.select({ id: notificationDeliveries.id }).from(notificationDeliveries).where(
|
|
296
|
+
and(eq(notificationDeliveries.status, 'pending'), lte(notificationDeliveries.nextAttemptAt, now)),
|
|
297
|
+
)
|
|
298
|
+
await Promise.allSettled(due.map((delivery) => attemptDelivery(delivery.id, now)))
|
|
299
|
+
return due.length
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
async function retryDelivery(deliveryId: number): Promise<boolean> {
|
|
303
|
+
const delivery = (await db.select().from(notificationDeliveries).where(eq(notificationDeliveries.id, deliveryId)))[0]
|
|
304
|
+
if (!delivery || delivery.status !== 'failed') return false
|
|
305
|
+
const now = Date.now()
|
|
306
|
+
await db.update(notificationDeliveries).set({
|
|
307
|
+
status: 'pending', maxAttempts: delivery.attemptCount + MAX_DELIVERY_ATTEMPTS,
|
|
308
|
+
nextAttemptAt: now, deliveredAt: null, lastError: null, updatedAt: now,
|
|
309
|
+
}).where(eq(notificationDeliveries.id, deliveryId))
|
|
310
|
+
await attemptDelivery(deliveryId, now)
|
|
311
|
+
return true
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async function purgeOldDeliveries(now = Date.now()): Promise<void> {
|
|
315
|
+
await db.delete(notificationDeliveries).where(lt(notificationDeliveries.createdAt, now - DELIVERY_RETENTION_MS))
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/** True when the latest alert-or-recovery event recorded for this source is an alert. */
|
|
319
|
+
async function hasOpenAlert(sourceId: number): Promise<boolean> {
|
|
320
|
+
// Suppressed rows count: an alert that quiet hours or a digest swallowed was still raised.
|
|
321
|
+
const [last] = await db.select({ eventType: notificationDeliveries.eventType }).from(notificationDeliveries)
|
|
322
|
+
.where(and(
|
|
323
|
+
eq(notificationDeliveries.sourceId, sourceId),
|
|
324
|
+
inArray(notificationDeliveries.eventType, [options.events.alert, options.events.recovery]),
|
|
325
|
+
))
|
|
326
|
+
.orderBy(desc(notificationDeliveries.id))
|
|
327
|
+
.limit(1)
|
|
328
|
+
return last?.eventType === options.events.alert
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Plans one event for one channel under its lock and records it. Resolves to the delivery to send right away, or
|
|
333
|
+
* null when nothing is to be sent now (suppressed, or held for quiet hours or a digest window). Recording is
|
|
334
|
+
* separate from sending so a caller can record every channel's delivery in its own transaction and send afterwards:
|
|
335
|
+
* a recorded delivery that was never sent is picked up by `processDue`.
|
|
336
|
+
*/
|
|
337
|
+
async function plan(channel: ChannelRow, vars: Record<string, string>, details: DeliveryDetails): Promise<number | null> {
|
|
338
|
+
return withChannelLock(channel.id, async () => {
|
|
339
|
+
const now = Date.now()
|
|
340
|
+
const delivery = await planDelivery(channel, parseAlertPolicy(channel.alertPolicy), details, now)
|
|
341
|
+
|
|
342
|
+
if (delivery.suppressionReason) {
|
|
343
|
+
await insertDelivery(channel, vars, details, { status: 'suppressed', reason: delivery.suppressionReason })
|
|
344
|
+
return null
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
const deliveryId = await insertDelivery(channel, vars, details, { status: 'pending', releaseAt: delivery.releaseAt, groupKey: delivery.groupKey })
|
|
348
|
+
return delivery.releaseAt <= now ? deliveryId : null
|
|
349
|
+
})
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** Plans one event for one channel and sends it right away when nothing holds it back. */
|
|
353
|
+
async function dispatch(channel: ChannelRow, vars: Record<string, string>, details: DeliveryDetails): Promise<void> {
|
|
354
|
+
const sendNow = await plan(channel, vars, details)
|
|
355
|
+
if (sendNow !== null) await attemptDelivery(sendNow)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Records a delivery that bypasses alert hygiene and sends it at once; resolves to the outcome. */
|
|
359
|
+
async function sendDirect(
|
|
360
|
+
channel: ChannelRow,
|
|
361
|
+
vars: Record<string, string>,
|
|
362
|
+
details: DeliveryDetails,
|
|
363
|
+
): Promise<{ ok: true } | { ok: false; error: string }> {
|
|
364
|
+
const deliveryId = await insertDelivery(channel, vars, details, { status: 'pending' })
|
|
365
|
+
await attemptDelivery(deliveryId)
|
|
366
|
+
const delivery = (await db.select().from(notificationDeliveries).where(eq(notificationDeliveries.id, deliveryId)))[0]!
|
|
367
|
+
return delivery.status === 'delivered' ? { ok: true } : { ok: false, error: delivery.lastError ?? 'Delivery failed' }
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── SMTP ───────────────────────────────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
async function isSmtpConfigured(): Promise<boolean> {
|
|
373
|
+
const smtp = (await db.select({ host: smtpSettings.host }).from(smtpSettings))[0]
|
|
374
|
+
return !!smtp?.host
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** Sends one message through the configured SMTP server. Throws when SMTP is not set up. */
|
|
378
|
+
async function sendSmtpMail(message: SmtpMessage): Promise<void> {
|
|
379
|
+
const smtp = (await db.select().from(smtpSettings))[0]
|
|
380
|
+
if (!smtp?.host) throw new Error('SMTP not configured')
|
|
381
|
+
|
|
382
|
+
let smtpUser = smtp.user
|
|
383
|
+
let smtpPass = smtp.password
|
|
384
|
+
if (smtp.vaultConfig) {
|
|
385
|
+
const ref = JSON.parse(smtp.vaultConfig) as VaultRef
|
|
386
|
+
const creds = await resolveSecret(ref)
|
|
387
|
+
smtpUser = creds['username'] ?? creds['user'] ?? smtpUser
|
|
388
|
+
smtpPass = creds['password'] ?? creds['value'] ?? smtpPass
|
|
389
|
+
// A vault reference means sign-in was intended; without a user nodemailer would skip auth silently.
|
|
390
|
+
if (!smtpUser) throw new Error('SMTP vault secret has no username: use a User / Password secret or map a username in a JSON secret')
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
394
|
+
const nodemailer: any = await import('nodemailer')
|
|
395
|
+
const nm = nodemailer.default ?? nodemailer
|
|
396
|
+
const transporter = nm.createTransport({
|
|
397
|
+
host: smtp.host,
|
|
398
|
+
port: smtp.port,
|
|
399
|
+
secure: !!smtp.secure,
|
|
400
|
+
auth: smtpUser ? { user: smtpUser, pass: smtpPass } : undefined,
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
const from = smtp.fromAddress
|
|
404
|
+
? `"${smtp.fromName}" <${smtp.fromAddress}>`
|
|
405
|
+
: smtp.fromName
|
|
406
|
+
|
|
407
|
+
await transporter.sendMail({
|
|
408
|
+
from,
|
|
409
|
+
to: message.to,
|
|
410
|
+
subject: message.subject,
|
|
411
|
+
text: message.text,
|
|
412
|
+
...(message.html ? { html: message.html } : {}),
|
|
413
|
+
...(message.headers ? { headers: message.headers } : {}),
|
|
414
|
+
})
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
return {
|
|
418
|
+
plan, dispatch, sendDirect, attemptDelivery, flushGroups, processDue, retryDelivery, purgeOldDeliveries, hasOpenAlert,
|
|
419
|
+
isSmtpConfigured, sendSmtpMail,
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
export type NotifyEngine = ReturnType<typeof createNotifyEngine>
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
export { CHANNEL_TYPES, TEMPLATE_KEYS, channelConfigError, configForEvent, eventOverridesError, type DefaultWording, type EventWording, ntfyConfigError, pushoverConfigError, telegramConfigError } from './channelInput.js'
|
|
2
|
+
export { CHANNEL_DESTINATIONS, CHANNEL_SECRETS } from './channelSecrets.js'
|
|
3
|
+
export {
|
|
4
|
+
createAlertPolicy, DEFAULT_ALERT_POLICY, isValidTimezone, isWithinQuietHours, localMinutes, quietHoursEndAt,
|
|
5
|
+
type AlertPolicy, type GroupingPolicy, type ThrottlePolicy,
|
|
6
|
+
} from './alertPolicy.js'
|
|
7
|
+
export { createNotifyDbModule, notifyMigrations } from './migrations.js'
|
|
8
|
+
export { defineNotifyTables, type NotifyTableOptions, type NotifyTables } from './tables.js'
|
|
9
|
+
export { buildWebhookRequest, escapeJsonString, placeholderInAuthority, MAX_DELIVERY_ATTEMPTS, RETRY_DELAYS_MS, substituteVars } from './template.js'
|
|
10
|
+
export {
|
|
11
|
+
createNotifyEngine, type ChannelRow, type DeliveryDetails, type DeliveryRow, type NotifyEngine, type NotifyEngineOptions, type SmtpMessage,
|
|
12
|
+
} from './engine.js'
|
|
13
|
+
export { readableTimestamp, renderSegments, type Notice, type NoticeFact, type NoticeSegment, type NoticeSeverity } from './notice.js'
|
|
14
|
+
export { createTransports, type TransportChannel, type TransportOptions, type Transports } from './transports.js'
|
|
15
|
+
export { createNotifyRoutes, type KeyResult, type NotifyRoutesOptions } from './routes.js'
|
|
16
|
+
export { createDeliveryWorker, type DeliveryWorkerOptions, type WorkerTask } from './worker.js'
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import type { DbModule, MigrationStep } from '@better-forge/db'
|
|
2
|
+
import { defineNotifyTables, type NotifyTableOptions } from './tables.js'
|
|
3
|
+
|
|
4
|
+
/** Notification channels, their delivery history and SMTP settings. Steps run in this order. */
|
|
5
|
+
export function notifyMigrations(options: Pick<NotifyTableOptions, 'sourceIdColumn' | 'sourceNameColumn' | 'smtpFromName'>): readonly MigrationStep[] {
|
|
6
|
+
const { sourceIdColumn, sourceNameColumn, smtpFromName } = options
|
|
7
|
+
return [
|
|
8
|
+
{ kind: 'exec', sql: `
|
|
9
|
+
CREATE TABLE IF NOT EXISTS notification_channels (
|
|
10
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
11
|
+
name TEXT NOT NULL,
|
|
12
|
+
type TEXT NOT NULL,
|
|
13
|
+
config TEXT NOT NULL DEFAULT '{}',
|
|
14
|
+
enabled INTEGER NOT NULL DEFAULT 1,
|
|
15
|
+
notify_on_recovery INTEGER NOT NULL DEFAULT 0,
|
|
16
|
+
created_at INTEGER NOT NULL,
|
|
17
|
+
updated_at INTEGER NOT NULL
|
|
18
|
+
);
|
|
19
|
+
` },
|
|
20
|
+
{ kind: 'exec', sql: `
|
|
21
|
+
CREATE TABLE IF NOT EXISTS notification_deliveries (
|
|
22
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
23
|
+
channel_id INTEGER NOT NULL,
|
|
24
|
+
channel_name TEXT NOT NULL,
|
|
25
|
+
channel_type TEXT NOT NULL,
|
|
26
|
+
${sourceIdColumn} INTEGER,
|
|
27
|
+
${sourceNameColumn} TEXT NOT NULL,
|
|
28
|
+
event_type TEXT NOT NULL,
|
|
29
|
+
status TEXT NOT NULL DEFAULT 'pending',
|
|
30
|
+
target_status TEXT NOT NULL,
|
|
31
|
+
previous_status TEXT NOT NULL,
|
|
32
|
+
variables TEXT NOT NULL,
|
|
33
|
+
attempt_count INTEGER NOT NULL DEFAULT 0,
|
|
34
|
+
max_attempts INTEGER NOT NULL DEFAULT 3,
|
|
35
|
+
next_attempt_at INTEGER,
|
|
36
|
+
last_attempt_at INTEGER,
|
|
37
|
+
delivered_at INTEGER,
|
|
38
|
+
last_error TEXT,
|
|
39
|
+
created_at INTEGER NOT NULL,
|
|
40
|
+
updated_at INTEGER NOT NULL
|
|
41
|
+
);
|
|
42
|
+
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_status_next ON notification_deliveries(status, next_attempt_at);
|
|
43
|
+
CREATE INDEX IF NOT EXISTS idx_notification_deliveries_created ON notification_deliveries(created_at);
|
|
44
|
+
` },
|
|
45
|
+
{ kind: 'exec', sql: `
|
|
46
|
+
CREATE TABLE IF NOT EXISTS notification_delivery_attempts (
|
|
47
|
+
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
48
|
+
delivery_id INTEGER NOT NULL,
|
|
49
|
+
attempt_number INTEGER NOT NULL,
|
|
50
|
+
status TEXT NOT NULL,
|
|
51
|
+
error TEXT,
|
|
52
|
+
started_at INTEGER NOT NULL,
|
|
53
|
+
completed_at INTEGER NOT NULL,
|
|
54
|
+
FOREIGN KEY (delivery_id) REFERENCES notification_deliveries(id) ON DELETE CASCADE
|
|
55
|
+
);
|
|
56
|
+
CREATE INDEX IF NOT EXISTS idx_notification_delivery_attempts_delivery ON notification_delivery_attempts(delivery_id, attempt_number);
|
|
57
|
+
` },
|
|
58
|
+
{ kind: 'exec', sql: `
|
|
59
|
+
CREATE TABLE IF NOT EXISTS smtp_settings (
|
|
60
|
+
id INTEGER PRIMARY KEY,
|
|
61
|
+
host TEXT NOT NULL DEFAULT '',
|
|
62
|
+
port INTEGER NOT NULL DEFAULT 587,
|
|
63
|
+
secure INTEGER NOT NULL DEFAULT 0,
|
|
64
|
+
user TEXT NOT NULL DEFAULT '',
|
|
65
|
+
password TEXT NOT NULL DEFAULT '',
|
|
66
|
+
from_address TEXT NOT NULL DEFAULT '',
|
|
67
|
+
from_name TEXT NOT NULL DEFAULT '${smtpFromName}',
|
|
68
|
+
updated_at INTEGER NOT NULL
|
|
69
|
+
);
|
|
70
|
+
` },
|
|
71
|
+
{ kind: 'column', sql: `ALTER TABLE smtp_settings ADD COLUMN vault_config TEXT`, desc: 'smtp_settings.vault_config' },
|
|
72
|
+
{ kind: 'column', sql: `ALTER TABLE notification_channels ADD COLUMN alert_policy TEXT NOT NULL DEFAULT '{}'`, desc: 'notification_channels.alert_policy' },
|
|
73
|
+
// SQLite cannot add a UNIQUE column. The application fills the keys of existing rows and then adds the unique index.
|
|
74
|
+
{ kind: 'column', sql: `ALTER TABLE notification_channels ADD COLUMN key TEXT NOT NULL DEFAULT ''`, desc: 'notification_channels.key' },
|
|
75
|
+
{ kind: 'column', sql: `ALTER TABLE notification_deliveries ADD COLUMN suppression_reason TEXT`, desc: 'notification_deliveries.suppression_reason' },
|
|
76
|
+
{ kind: 'column', sql: `ALTER TABLE notification_deliveries ADD COLUMN group_key TEXT`, desc: 'notification_deliveries.group_key' },
|
|
77
|
+
{ kind: 'exec', sql: `CREATE INDEX IF NOT EXISTS idx_notification_deliveries_group ON notification_deliveries(group_key, status)` },
|
|
78
|
+
{ kind: 'exec', sql: `CREATE INDEX IF NOT EXISTS idx_notification_deliveries_throttle ON notification_deliveries(channel_id, ${sourceIdColumn}, event_type, created_at)` },
|
|
79
|
+
]
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* The notification tables and their migrations, for the application's list of database modules. The unique index on
|
|
84
|
+
* the channel \`key\` and the column that backs it are created by the application, once existing rows were given a key.
|
|
85
|
+
*/
|
|
86
|
+
export function createNotifyDbModule(options: NotifyTableOptions) {
|
|
87
|
+
return { id: 'notify', tables: defineNotifyTables(options), migrations: notifyMigrations(options) } satisfies DbModule
|
|
88
|
+
}
|
package/src/notice.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/** How serious an event is; the rich channels paint it in a colour family and the push channels map it to a priority. */
|
|
2
|
+
export type NoticeSeverity = 'critical' | 'warning' | 'ok'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A piece of a headline. Plain text stays as written; a `subject` (what the event is about) and a `state` (what became
|
|
6
|
+
* of it) are the parts a channel emphasises in its own markup. A `kind` names what sort of thing the subject is
|
|
7
|
+
* ("Server"), which a channel may leave out where its title is already crowded.
|
|
8
|
+
*/
|
|
9
|
+
export type NoticeSegment = string | { kind: string } | { subject: string } | { state: string }
|
|
10
|
+
|
|
11
|
+
export interface NoticeFact {
|
|
12
|
+
label: string
|
|
13
|
+
value: string
|
|
14
|
+
/** The label for channels that have room for a longer one. */
|
|
15
|
+
fullLabel?: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* What the channels say about one event, independent of the application that raised it. The application builds it
|
|
20
|
+
* from its own variables; every channel turns it into its own payload.
|
|
21
|
+
*/
|
|
22
|
+
export interface Notice {
|
|
23
|
+
severity: NoticeSeverity
|
|
24
|
+
headline: NoticeSegment[]
|
|
25
|
+
/** The line under the headline in channels that have one. */
|
|
26
|
+
subtitle: NoticeSegment[]
|
|
27
|
+
facts: NoticeFact[]
|
|
28
|
+
/** The error or details of the event, shown after the facts. */
|
|
29
|
+
detail?: { label: string; value: string }
|
|
30
|
+
/** When the event happened, as an ISO instant (anything else is shown as is). */
|
|
31
|
+
at: string
|
|
32
|
+
/** Names `at` in the footer ("Checked at"). */
|
|
33
|
+
atLabel: string
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** How a channel writes the emphasised segments of a headline. */
|
|
37
|
+
export interface SegmentMarkup {
|
|
38
|
+
/** Applied to every text segment, emphasised or not. */
|
|
39
|
+
text?: (value: string) => string
|
|
40
|
+
/** Defaults to writing the kind as plain text. */
|
|
41
|
+
kind?: (value: string) => string
|
|
42
|
+
subject: (value: string) => string
|
|
43
|
+
state: (value: string) => string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function renderSegments(segments: NoticeSegment[], markup: SegmentMarkup): string {
|
|
47
|
+
const text = markup.text ?? ((value: string) => value)
|
|
48
|
+
return segments.map((segment) => {
|
|
49
|
+
if (typeof segment === 'string') return text(segment)
|
|
50
|
+
if ('kind' in segment) return markup.kind ? markup.kind(text(segment.kind)) : text(segment.kind)
|
|
51
|
+
if ('subject' in segment) return markup.subject(text(segment.subject))
|
|
52
|
+
return markup.state(text(segment.state))
|
|
53
|
+
}).join('')
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const plainMarkup: SegmentMarkup = { subject: (value) => value, state: (value) => value }
|
|
57
|
+
|
|
58
|
+
/** A headline without any markup. */
|
|
59
|
+
export function plainSegments(segments: NoticeSegment[]): string {
|
|
60
|
+
return renderSegments(segments, plainMarkup)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** "2026-10-09T17:00:22.988Z" → "2026-10-09 17:00:22 UTC"; anything that is not an ISO instant is shown as is. */
|
|
64
|
+
export function readableTimestamp(iso: string | undefined): string {
|
|
65
|
+
if (!iso) return 'unknown'
|
|
66
|
+
const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2}:\d{2})(?:\.\d+)?Z$/.exec(iso)
|
|
67
|
+
return match ? `${match[1]} ${match[2]} UTC` : iso
|
|
68
|
+
}
|