@zerotal/notifications 1.0.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/CHANGELOG.md +69 -0
- package/LICENSE +21 -0
- package/README.md +138 -0
- package/package.json +58 -0
- package/src/BroadcastChannel.ts +80 -0
- package/src/BroadcastMessage.ts +32 -0
- package/src/BroadcastNotificationJob.ts +51 -0
- package/src/DatabaseChannel.ts +295 -0
- package/src/MailChannel.ts +93 -0
- package/src/Notifiable.ts +95 -0
- package/src/Notification.ts +114 -0
- package/src/NotificationFake.ts +282 -0
- package/src/NotificationManager.ts +269 -0
- package/src/NotificationRegistry.ts +37 -0
- package/src/OnDemandNotifiable.ts +46 -0
- package/src/SendNotificationJob.ts +67 -0
- package/src/SlackChannel.ts +76 -0
- package/src/SmsChannel.ts +151 -0
- package/src/admin.ts +219 -0
- package/src/commands/NotificationsPruneCommand.ts +54 -0
- package/src/commands/NotificationsTestCommand.ts +63 -0
- package/src/commands/index.ts +2 -0
- package/src/config.ts +122 -0
- package/src/drivers/LogDriver.ts +41 -0
- package/src/drivers/MailDriver.ts +54 -0
- package/src/drivers/ResendDriver.ts +54 -0
- package/src/drivers/SmtpDriver.ts +510 -0
- package/src/errors.ts +163 -0
- package/src/events.ts +70 -0
- package/src/facades/Notify.ts +3 -0
- package/src/global.d.ts +31 -0
- package/src/index.ts +68 -0
- package/src/messages/MailMessage.ts +342 -0
- package/src/observability.ts +146 -0
- package/src/provider/NotificationProvider.ts +62 -0
- package/src/serialization.ts +188 -0
- package/src/stats.ts +93 -0
- package/src/types.ts +129 -0
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { ServiceProvider } from "@zerotal/core";
|
|
2
|
+
import type { AppEnvironment } from "@zerotal/core";
|
|
3
|
+
import type { ConfigManager } from "@zerotal/core/config";
|
|
4
|
+
import { NotificationManager } from "../NotificationManager.ts";
|
|
5
|
+
import { NotificationConfig } from "../config.ts";
|
|
6
|
+
import { installNotificationsObservability } from "../observability.ts";
|
|
7
|
+
import { installNotificationStats } from "../stats.ts";
|
|
8
|
+
import { installNotificationsAdmin } from "../admin.ts";
|
|
9
|
+
|
|
10
|
+
declare module "@zerotal/core" {
|
|
11
|
+
interface ContainerBindings {
|
|
12
|
+
notifications: NotificationManager;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export class NotificationProvider extends ServiceProvider {
|
|
17
|
+
static override provides = ["notifications"] as const;
|
|
18
|
+
static override environments: AppEnvironment[] = ["web", "console", "worker", "test"];
|
|
19
|
+
|
|
20
|
+
private _disposeObservability: (() => void) | undefined = undefined;
|
|
21
|
+
private _disposeStats: (() => void) | undefined = undefined;
|
|
22
|
+
|
|
23
|
+
override onRegister(): void {
|
|
24
|
+
this.app.container.singleton("notifications", () => {
|
|
25
|
+
const config = this.app.container.makeSync("config") as ConfigManager;
|
|
26
|
+
const cfg = config.get<ReturnType<typeof NotificationConfig>>(
|
|
27
|
+
"notifications",
|
|
28
|
+
NotificationConfig(),
|
|
29
|
+
);
|
|
30
|
+
return new NotificationManager(cfg);
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
override async onBooting(): Promise<void> {
|
|
35
|
+
// The admin panel binds its contribution surface during registration, so the
|
|
36
|
+
// booting phase reaches it regardless of provider order.
|
|
37
|
+
installNotificationsAdmin(this.app);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
override async onBooted(): Promise<void> {
|
|
41
|
+
await this.app.container.make("notifications");
|
|
42
|
+
this._disposeObservability = installNotificationsObservability(this.app);
|
|
43
|
+
this._disposeStats = installNotificationStats();
|
|
44
|
+
|
|
45
|
+
const runner = this.app.container.tryMake("commands");
|
|
46
|
+
if (!runner) return;
|
|
47
|
+
|
|
48
|
+
runner.registerLazy("notifications:prune", () =>
|
|
49
|
+
import("../commands/NotificationsPruneCommand.ts").then((m) => m.NotificationsPruneCommand),
|
|
50
|
+
);
|
|
51
|
+
runner.registerLazy("notifications:test", () =>
|
|
52
|
+
import("../commands/NotificationsTestCommand.ts").then((m) => m.NotificationsTestCommand),
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
override async onStopping(): Promise<void> {
|
|
57
|
+
this._disposeObservability?.();
|
|
58
|
+
this._disposeObservability = undefined;
|
|
59
|
+
this._disposeStats?.();
|
|
60
|
+
this._disposeStats = undefined;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Queue serialization for notifications.
|
|
3
|
+
*
|
|
4
|
+
* A notification queued with `notifyLater()` may be delivered by a different
|
|
5
|
+
* process from the one that queued it, so both the notification and its
|
|
6
|
+
* recipient have to survive a round trip through JSON. This module defines that
|
|
7
|
+
* wire form and rebuilds both sides of it.
|
|
8
|
+
*
|
|
9
|
+
* The recipient crosses as a **snapshot**, not a live model: channels read the
|
|
10
|
+
* `Notifiable` contract (`id`, `email`, `name`, `phone`) plus whatever else the
|
|
11
|
+
* model exposes through `toJSON()`. A hydrated notifiable is a plain object, so
|
|
12
|
+
* a `to*()` method that calls a model *method* on its recipient will not find it
|
|
13
|
+
* — read fields, not behaviour, when a notification is queued.
|
|
14
|
+
*/
|
|
15
|
+
import { NotificationRegistry } from "./NotificationRegistry.ts";
|
|
16
|
+
import { UnknownNotificationTypeError } from "./errors.ts";
|
|
17
|
+
import type { Notification } from "./Notification.ts";
|
|
18
|
+
import type { Notifiable } from "./types.ts";
|
|
19
|
+
|
|
20
|
+
/** The serialized form of a notifiable — its public fields plus routing hints. */
|
|
21
|
+
export interface SerializedNotifiable extends Record<string, unknown> {
|
|
22
|
+
id: number | string;
|
|
23
|
+
/** The recipient's model/class name, so the database channel keeps its `notifiable_type`. */
|
|
24
|
+
__type?: string;
|
|
25
|
+
/** The resolved broadcast channel, captured because the method itself cannot cross. */
|
|
26
|
+
__broadcastChannel?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** The serialized form of one queued notification. */
|
|
30
|
+
export interface SerializedNotification {
|
|
31
|
+
notifiable: SerializedNotifiable;
|
|
32
|
+
type: string;
|
|
33
|
+
data: Record<string, unknown>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** True for values that survive `JSON.stringify` without surprises. */
|
|
37
|
+
function isPlain(value: unknown): boolean {
|
|
38
|
+
if (value === null) return true;
|
|
39
|
+
const t = typeof value;
|
|
40
|
+
if (t === "string" || t === "number" || t === "boolean") return true;
|
|
41
|
+
if (value instanceof Date) return true;
|
|
42
|
+
if (Array.isArray(value)) return value.every(isPlain);
|
|
43
|
+
if (t === "object") {
|
|
44
|
+
const proto = Object.getPrototypeOf(value as object) as unknown;
|
|
45
|
+
return proto === Object.prototype || proto === null;
|
|
46
|
+
}
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Snapshot a notifiable for the queue.
|
|
52
|
+
*
|
|
53
|
+
* ORM models expose `toJSON()`, which already strips internal state (`_original`,
|
|
54
|
+
* `_exists`, …) and honours `hidden`/`visible`; that is the preferred source. For
|
|
55
|
+
* a plain object, own enumerable non-underscore fields are copied instead. Values
|
|
56
|
+
* that would not survive JSON (class instances, functions) are dropped rather
|
|
57
|
+
* than silently corrupted.
|
|
58
|
+
*/
|
|
59
|
+
export function serializeNotifiable(notifiable: Notifiable): SerializedNotifiable {
|
|
60
|
+
const candidate = notifiable as unknown as { toJSON?: () => Record<string, unknown> };
|
|
61
|
+
const source =
|
|
62
|
+
typeof candidate.toJSON === "function"
|
|
63
|
+
? candidate.toJSON()
|
|
64
|
+
: (notifiable as unknown as Record<string, unknown>);
|
|
65
|
+
|
|
66
|
+
const out: SerializedNotifiable = { id: notifiable.id };
|
|
67
|
+
for (const [key, value] of Object.entries(source)) {
|
|
68
|
+
if (key.startsWith("_") || key.startsWith("$")) continue;
|
|
69
|
+
if (!isPlain(value)) continue;
|
|
70
|
+
out[key] = value;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// `id` may be hidden from toJSON() — the contract needs it regardless.
|
|
74
|
+
out.id = notifiable.id;
|
|
75
|
+
|
|
76
|
+
const type = notifiable.constructor?.name;
|
|
77
|
+
if (type && type !== "Object") out.__type = type;
|
|
78
|
+
|
|
79
|
+
// Resolve the custom broadcast channel now: it is a method, and methods do not
|
|
80
|
+
// cross a queue boundary.
|
|
81
|
+
const channel = notifiable.receivesBroadcastNotificationsOn?.();
|
|
82
|
+
if (channel !== undefined) out.__broadcastChannel = channel;
|
|
83
|
+
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** Rebuild a notifiable snapshot into something the channels can consume. */
|
|
88
|
+
export function hydrateNotifiable(data: SerializedNotifiable): Notifiable {
|
|
89
|
+
const { __broadcastChannel, ...fields } = data;
|
|
90
|
+
const notifiable = fields as unknown as Notifiable;
|
|
91
|
+
if (__broadcastChannel !== undefined) {
|
|
92
|
+
notifiable.receivesBroadcastNotificationsOn = () => __broadcastChannel;
|
|
93
|
+
}
|
|
94
|
+
return notifiable;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The notifiable's originating class name, used as `notifiable_type`. */
|
|
98
|
+
export function notifiableType(notifiable: Notifiable): string {
|
|
99
|
+
const explicit = (notifiable as unknown as { __type?: string }).__type;
|
|
100
|
+
if (typeof explicit === "string" && explicit.length > 0) return explicit;
|
|
101
|
+
const name = notifiable.constructor?.name;
|
|
102
|
+
return name && name !== "Object" ? name : "Notifiable";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Serialize one notification plus its recipient into the queued job payload. */
|
|
106
|
+
export function serializeNotification(
|
|
107
|
+
notifiable: Notifiable,
|
|
108
|
+
notification: Notification,
|
|
109
|
+
): SerializedNotification {
|
|
110
|
+
return {
|
|
111
|
+
notifiable: serializeNotifiable(notifiable),
|
|
112
|
+
type: notification.constructor.name,
|
|
113
|
+
data: notification.payload(),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
let _discovered = false;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Import `app/notifications/*.ts` so every notification class registers itself.
|
|
121
|
+
*
|
|
122
|
+
* A worker thread does not boot a full application, so the classes it must
|
|
123
|
+
* rebuild have never been imported there. This runs once, lazily, the first time
|
|
124
|
+
* a name fails to resolve — an app whose notifications are all already imported
|
|
125
|
+
* never pays for it.
|
|
126
|
+
*/
|
|
127
|
+
export async function discoverNotifications(): Promise<void> {
|
|
128
|
+
if (_discovered) return;
|
|
129
|
+
_discovered = true;
|
|
130
|
+
|
|
131
|
+
const dir = `${process.cwd()}/app/notifications`;
|
|
132
|
+
try {
|
|
133
|
+
const glob = new Bun.Glob("**/*.ts");
|
|
134
|
+
for await (const file of glob.scan({ cwd: dir, absolute: true })) {
|
|
135
|
+
const mod = (await import(`file://${file.replace(/\\/g, "/")}`)) as Record<string, unknown>;
|
|
136
|
+
for (const exported of Object.values(mod)) {
|
|
137
|
+
if (typeof exported === "function" && isNotificationClass(exported as AnyConstructor)) {
|
|
138
|
+
NotificationRegistry.register(exported as never);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
} catch {
|
|
143
|
+
/* app/notifications/ doesn't exist — nothing to discover */
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/** Any constructor, used only for walking a prototype chain. */
|
|
148
|
+
type AnyConstructor = new (...args: never[]) => unknown;
|
|
149
|
+
|
|
150
|
+
/** Walk the prototype chain looking for the `Notification` base class by name. */
|
|
151
|
+
function isNotificationClass(value: AnyConstructor): boolean {
|
|
152
|
+
let proto: unknown = Object.getPrototypeOf(value) as unknown;
|
|
153
|
+
while (typeof proto === "function") {
|
|
154
|
+
if ((proto as AnyConstructor).name === "Notification") return true;
|
|
155
|
+
proto = Object.getPrototypeOf(proto) as unknown;
|
|
156
|
+
}
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Rebuild a notification from its class name and serialized state, discovering
|
|
162
|
+
* app notification classes on demand when the name is not yet registered.
|
|
163
|
+
*
|
|
164
|
+
* @throws {UnknownNotificationTypeError} when the class cannot be found.
|
|
165
|
+
*/
|
|
166
|
+
export async function hydrateNotification(
|
|
167
|
+
type: string,
|
|
168
|
+
data: Record<string, unknown>,
|
|
169
|
+
): Promise<Notification> {
|
|
170
|
+
let NotificationClass = NotificationRegistry.resolve(type);
|
|
171
|
+
if (!NotificationClass) {
|
|
172
|
+
await discoverNotifications();
|
|
173
|
+
NotificationClass = NotificationRegistry.resolve(type);
|
|
174
|
+
}
|
|
175
|
+
if (!NotificationClass) {
|
|
176
|
+
throw new UnknownNotificationTypeError(type, [...NotificationRegistry.all().keys()]);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
if (typeof NotificationClass.fromPayload === "function") {
|
|
180
|
+
return NotificationClass.fromPayload(data);
|
|
181
|
+
}
|
|
182
|
+
return Object.assign(Object.create(NotificationClass.prototype) as Notification, data);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** @internal Reset discovery memoization. Tests only. */
|
|
186
|
+
export function _resetDiscovery(): void {
|
|
187
|
+
_discovered = false;
|
|
188
|
+
}
|
package/src/stats.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-process delivery counters, kept for the admin panel.
|
|
3
|
+
*
|
|
4
|
+
* Best-effort and deliberately bounded: this is the "what is happening right
|
|
5
|
+
* now" view an operator wants when a channel starts failing, not a history. It
|
|
6
|
+
* is not persisted, and it resets when the process does — the durable record of
|
|
7
|
+
* a notification is the database channel.
|
|
8
|
+
*/
|
|
9
|
+
import { FrameworkEvents } from "@zerotal/core";
|
|
10
|
+
import { NotificationSent } from "./events.ts";
|
|
11
|
+
|
|
12
|
+
/** The most recent deliveries retained for the panel's feed. */
|
|
13
|
+
const MAX_RECENT = 200;
|
|
14
|
+
|
|
15
|
+
/** One recorded delivery attempt on one channel. */
|
|
16
|
+
export interface RecentDelivery {
|
|
17
|
+
at: number;
|
|
18
|
+
className: string;
|
|
19
|
+
channel: string;
|
|
20
|
+
notifiable: string;
|
|
21
|
+
ok: boolean;
|
|
22
|
+
durationMs: number;
|
|
23
|
+
error: string | undefined;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Per-channel totals since the process booted. */
|
|
27
|
+
export interface ChannelStat {
|
|
28
|
+
channel: string;
|
|
29
|
+
sent: number;
|
|
30
|
+
failed: number;
|
|
31
|
+
/** Mean duration in milliseconds across successful sends. */
|
|
32
|
+
avgMs: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const _recent: RecentDelivery[] = [];
|
|
36
|
+
const _byChannel = new Map<string, { sent: number; failed: number; totalMs: number }>();
|
|
37
|
+
let _installed: (() => void) | undefined;
|
|
38
|
+
|
|
39
|
+
/** Subscribe the counters to the delivery event bus. Idempotent. */
|
|
40
|
+
export function installNotificationStats(): () => void {
|
|
41
|
+
if (_installed) return _installed;
|
|
42
|
+
|
|
43
|
+
const off = FrameworkEvents.on(NotificationSent, (e) => {
|
|
44
|
+
_recent.push({
|
|
45
|
+
at: Date.now(),
|
|
46
|
+
className: e.className,
|
|
47
|
+
channel: e.channel,
|
|
48
|
+
notifiable: e.notifiable,
|
|
49
|
+
ok: e.ok,
|
|
50
|
+
durationMs: e.durationMs,
|
|
51
|
+
error: e.error,
|
|
52
|
+
});
|
|
53
|
+
if (_recent.length > MAX_RECENT) _recent.splice(0, _recent.length - MAX_RECENT);
|
|
54
|
+
|
|
55
|
+
const stat = _byChannel.get(e.channel) ?? { sent: 0, failed: 0, totalMs: 0 };
|
|
56
|
+
if (e.ok) {
|
|
57
|
+
stat.sent++;
|
|
58
|
+
stat.totalMs += e.durationMs;
|
|
59
|
+
} else {
|
|
60
|
+
stat.failed++;
|
|
61
|
+
}
|
|
62
|
+
_byChannel.set(e.channel, stat);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
_installed = () => {
|
|
66
|
+
off();
|
|
67
|
+
_installed = undefined;
|
|
68
|
+
};
|
|
69
|
+
return _installed;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Recent delivery attempts, newest first. */
|
|
73
|
+
export function recentDeliveries(): RecentDelivery[] {
|
|
74
|
+
return [..._recent].reverse();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Per-channel totals, busiest first. */
|
|
78
|
+
export function channelStats(): ChannelStat[] {
|
|
79
|
+
return [..._byChannel.entries()]
|
|
80
|
+
.map(([channel, s]) => ({
|
|
81
|
+
channel,
|
|
82
|
+
sent: s.sent,
|
|
83
|
+
failed: s.failed,
|
|
84
|
+
avgMs: s.sent > 0 ? Math.round(s.totalMs / s.sent) : 0,
|
|
85
|
+
}))
|
|
86
|
+
.sort((a, b) => b.sent + b.failed - (a.sent + a.failed));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** @internal Clear the counters. Tests only. */
|
|
90
|
+
export function _resetStats(): void {
|
|
91
|
+
_recent.length = 0;
|
|
92
|
+
_byChannel.clear();
|
|
93
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/** The entity receiving the notification (typically a user). */
|
|
2
|
+
export interface Notifiable {
|
|
3
|
+
id: number | string;
|
|
4
|
+
email?: string;
|
|
5
|
+
name?: string;
|
|
6
|
+
/** Phone number in E.164 format, required when using the sms channel. */
|
|
7
|
+
phone?: string;
|
|
8
|
+
/**
|
|
9
|
+
* Override the channel this notifiable receives broadcast notifications on.
|
|
10
|
+
* Default: `notifications.{id}` (broadcast as a private channel). Used by the 'broadcast' channel.
|
|
11
|
+
*/
|
|
12
|
+
receivesBroadcastNotificationsOn?(): string;
|
|
13
|
+
/**
|
|
14
|
+
* Per-channel destination override, consulted before the default field.
|
|
15
|
+
*
|
|
16
|
+
* Return the address a channel should deliver to — an email for `"mail"`, an
|
|
17
|
+
* E.164 number for `"sms"`, a webhook URL for `"slack"` — or `undefined` to
|
|
18
|
+
* fall back. Lets one model route differently per channel without every
|
|
19
|
+
* notification having to know how.
|
|
20
|
+
*
|
|
21
|
+
* @example
|
|
22
|
+
* routeNotificationFor(channel: string) {
|
|
23
|
+
* return channel === "mail" ? this.billingEmail : undefined;
|
|
24
|
+
* }
|
|
25
|
+
*/
|
|
26
|
+
routeNotificationFor?(channel: string): string | undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A delivery channel. Every built-in channel implements this, and so does any
|
|
31
|
+
* channel registered with `NotificationManager.extend()`.
|
|
32
|
+
*/
|
|
33
|
+
export interface NotificationChannel {
|
|
34
|
+
send(notifiable: Notifiable, notification: NotificationLike): Promise<void>;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The shape a channel sees. Declared structurally so `types.ts` stays free of a
|
|
39
|
+
* runtime import of the `Notification` class.
|
|
40
|
+
*/
|
|
41
|
+
export interface NotificationLike {
|
|
42
|
+
channels(notifiable?: Notifiable): string[];
|
|
43
|
+
constructor: { name: string };
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Mail-channel settings (driver selection + the default sender + credentials). */
|
|
47
|
+
export interface MailConfigShape {
|
|
48
|
+
/** Transport for the mail channel. Default: 'log' (prints to console). */
|
|
49
|
+
driver: "log" | "smtp" | "resend";
|
|
50
|
+
/** Default From address, used unless a MailMessage overrides it with `from()`. */
|
|
51
|
+
from: {
|
|
52
|
+
address: string;
|
|
53
|
+
name: string;
|
|
54
|
+
};
|
|
55
|
+
/** SMTP options (used when driver === 'smtp'). */
|
|
56
|
+
smtp: {
|
|
57
|
+
host: string;
|
|
58
|
+
port: number;
|
|
59
|
+
/** TLS on connect (port 465); false = plain, upgraded via STARTTLS when offered (port 587). */
|
|
60
|
+
secure: boolean;
|
|
61
|
+
username: string;
|
|
62
|
+
password: string;
|
|
63
|
+
/**
|
|
64
|
+
* Send credentials even when the connection is not encrypted. Off by default:
|
|
65
|
+
* `AUTH LOGIN`/`PLAIN` are base64, so a plaintext session hands the password
|
|
66
|
+
* to anyone on the path. Turn on only for a trusted local relay.
|
|
67
|
+
*/
|
|
68
|
+
allowInsecureAuth?: boolean;
|
|
69
|
+
/** Reject servers presenting an untrusted certificate. Default: true. */
|
|
70
|
+
rejectUnauthorized?: boolean;
|
|
71
|
+
/** Per-reply timeout in milliseconds. Default: 30000. */
|
|
72
|
+
timeoutMs?: number;
|
|
73
|
+
/** Name sent in the EHLO greeting. Default: 'zerotal'. */
|
|
74
|
+
clientName?: string;
|
|
75
|
+
};
|
|
76
|
+
/** Resend options (used when driver === 'resend'). */
|
|
77
|
+
resend: {
|
|
78
|
+
apiKey: string;
|
|
79
|
+
};
|
|
80
|
+
/** Log options (used when driver === 'log'). */
|
|
81
|
+
log: {
|
|
82
|
+
/** 'console' writes to stdout; a path writes to a file. */
|
|
83
|
+
channel: "console" | string;
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export interface TwilioConfigShape {
|
|
88
|
+
accountSid: string;
|
|
89
|
+
authToken: string;
|
|
90
|
+
from: string;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface VonageConfigShape {
|
|
94
|
+
apiKey: string;
|
|
95
|
+
apiSecret: string;
|
|
96
|
+
from: string;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export interface SmsConfigShape {
|
|
100
|
+
driver: "twilio" | "vonage";
|
|
101
|
+
twilio?: TwilioConfigShape;
|
|
102
|
+
vonage?: VonageConfigShape;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export interface NotificationConfigShape {
|
|
106
|
+
database: {
|
|
107
|
+
/** Table name for stored notifications. Default: 'notifications' */
|
|
108
|
+
table: string;
|
|
109
|
+
};
|
|
110
|
+
/**
|
|
111
|
+
* Mail-channel settings. Always present (defaults to the `log` driver), so the
|
|
112
|
+
* `mail` channel works out of the box without extra configuration.
|
|
113
|
+
*/
|
|
114
|
+
mail: MailConfigShape;
|
|
115
|
+
/**
|
|
116
|
+
* Slack incoming webhook settings.
|
|
117
|
+
* The per-notification toSlack() method provides the webhookUrl, so this is
|
|
118
|
+
* only needed if you want a global fallback URL.
|
|
119
|
+
*/
|
|
120
|
+
slack?: {
|
|
121
|
+
/** Default webhook URL — can be overridden per-notification in toSlack(). */
|
|
122
|
+
webhook?: string;
|
|
123
|
+
};
|
|
124
|
+
/**
|
|
125
|
+
* SMS provider settings (Twilio or Vonage).
|
|
126
|
+
* Required when any notification uses the 'sms' channel.
|
|
127
|
+
*/
|
|
128
|
+
sms?: SmsConfigShape;
|
|
129
|
+
}
|