@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.
@@ -0,0 +1,67 @@
1
+ import { Job, JobRegistry } from "@zerotal/queue";
2
+ import type { Notifiable } from "./types.ts";
3
+ import type { Notification } from "./Notification.ts";
4
+ import type { NotificationManager } from "./NotificationManager.ts";
5
+ import {
6
+ hydrateNotifiable,
7
+ hydrateNotification,
8
+ serializeNotification,
9
+ type SerializedNotification,
10
+ } from "./serialization.ts";
11
+
12
+ /**
13
+ * Background job that sends a queued Notification. Dispatched by `Notify.queue()`
14
+ * and `notifyLater()`.
15
+ *
16
+ * The job carries the notification in one of two states. Dispatched in-process it
17
+ * holds the live objects and uses them directly. Restored from a persistent driver
18
+ * (SQLite/Redis) it holds only the serialized snapshot written by `payload()`, and
19
+ * rebuilds both sides in `handle()` — asynchronously, because resolving a
20
+ * notification class may require importing `app/notifications/`.
21
+ */
22
+ export class SendNotificationJob extends Job {
23
+ override readonly queue = "notifications";
24
+
25
+ private readonly _serialized: SerializedNotification | undefined;
26
+
27
+ constructor(
28
+ private readonly _notifiable?: Notifiable,
29
+ private readonly _notification?: Notification,
30
+ serialized?: SerializedNotification,
31
+ ) {
32
+ super();
33
+ this._serialized = serialized;
34
+ }
35
+
36
+ /** The wire form stored by persistent queue drivers. */
37
+ override payload(): Record<string, unknown> {
38
+ if (this._serialized) return { ...this._serialized };
39
+ return {
40
+ ...serializeNotification(this._notifiable as Notifiable, this._notification as Notification),
41
+ };
42
+ }
43
+
44
+ /** Rebuild the job from a stored payload. Called by the queue on the worker side. */
45
+ static fromPayload(data: Record<string, unknown>): SendNotificationJob {
46
+ return new SendNotificationJob(undefined, undefined, data as unknown as SerializedNotification);
47
+ }
48
+
49
+ async handle(): Promise<void> {
50
+ const { currentApp } = await import("@zerotal/core");
51
+ const manager = currentApp().container.makeSync("notifications") as NotificationManager;
52
+
53
+ const [notifiable, notification] = await this._resolve();
54
+ await manager.send(notifiable, notification);
55
+ }
56
+
57
+ /** The live pair when dispatched in-process; the rebuilt pair when restored. */
58
+ private async _resolve(): Promise<[Notifiable, Notification]> {
59
+ if (this._notifiable && this._notification) {
60
+ return [this._notifiable, this._notification];
61
+ }
62
+ const s = this._serialized as SerializedNotification;
63
+ return [hydrateNotifiable(s.notifiable), await hydrateNotification(s.type, s.data)];
64
+ }
65
+ }
66
+
67
+ JobRegistry.register(SendNotificationJob);
@@ -0,0 +1,76 @@
1
+ import type { Notifiable } from "./types.ts";
2
+ import type { Notification } from "./Notification.ts";
3
+ import { NotificationDeliveryError, NotificationChannelNotConfiguredError } from "./errors.ts";
4
+
5
+ /**
6
+ * Payload returned by notification.toSlack().
7
+ *
8
+ * @example
9
+ * toSlack(_notifiable: Notifiable): SlackMessage {
10
+ * return {
11
+ * webhookUrl: 'https://hooks.slack.com/services/...',
12
+ * text: `Order #${this.order.id} was shipped!`,
13
+ * // Optional Block Kit blocks for rich formatting:
14
+ * // blocks: [{ type: 'section', text: { type: 'mrkdwn', text: '...' } }],
15
+ * };
16
+ * }
17
+ */
18
+ export interface SlackMessage {
19
+ /**
20
+ * Incoming webhook URL. Optional when the notifiable routes `"slack"` or
21
+ * `config.slack.webhook` is set — the message value wins over both.
22
+ */
23
+ webhookUrl?: string;
24
+ /** Fallback plain text (required by Slack even when blocks are present). */
25
+ text: string;
26
+ /** Optional Block Kit blocks for rich formatting. */
27
+ blocks?: unknown[];
28
+ }
29
+
30
+ /**
31
+ * Slack notification channel — POSTs a JSON payload to an incoming webhook URL.
32
+ *
33
+ * No npm dependencies — uses the global fetch API.
34
+ *
35
+ * Usage in a Notification:
36
+ * channels() { return ['slack']; }
37
+ * toSlack(_notifiable: Notifiable): SlackMessage {
38
+ * return { webhookUrl: 'https://hooks.slack.com/...', text: 'Hello!' };
39
+ * }
40
+ */
41
+ export class SlackChannel {
42
+ constructor(private readonly config: { webhook?: string } = {}) {}
43
+
44
+ async send(notifiable: Notifiable, notification: Notification): Promise<void> {
45
+ const message = await notification.toSlack(notifiable);
46
+
47
+ // Most specific wins: the message's own URL, then the recipient's route,
48
+ // then the global webhook from config/notifications.ts.
49
+ const webhookUrl =
50
+ message.webhookUrl ?? notifiable.routeNotificationFor?.("slack") ?? this.config.webhook;
51
+
52
+ if (!webhookUrl) {
53
+ throw new NotificationChannelNotConfiguredError(
54
+ "slack",
55
+ `No webhook URL for ${notification.constructor.name}. Return one from toSlack(), ` +
56
+ `route it on the notifiable, or set slack: { webhook: "..." } in config/notifications.ts.`,
57
+ );
58
+ }
59
+
60
+ const res = await fetch(webhookUrl, {
61
+ method: "POST",
62
+ headers: { "Content-Type": "application/json" },
63
+ body: JSON.stringify({
64
+ text: message.text,
65
+ ...(message.blocks ? { blocks: message.blocks } : {}),
66
+ }),
67
+ });
68
+
69
+ if (!res.ok) {
70
+ const body = await res.text().catch(() => "");
71
+ throw new NotificationDeliveryError(
72
+ `[Zerotal/notifications] Slack webhook returned ${res.status}: ${body}`,
73
+ );
74
+ }
75
+ }
76
+ }
@@ -0,0 +1,151 @@
1
+ import type { Notifiable } from "./types.ts";
2
+ import type { Notification } from "./Notification.ts";
3
+ import type { SmsConfigShape } from "./types.ts";
4
+ import {
5
+ NotificationDeliveryError,
6
+ NotificationChannelUnavailableError,
7
+ UnknownSmsDriverError,
8
+ } from "./errors.ts";
9
+
10
+ /**
11
+ * Payload returned by notification.toSms().
12
+ *
13
+ * @example
14
+ * toSms(_notifiable: Notifiable): SmsMessage {
15
+ * return { body: `Your verification code is ${this.code}` };
16
+ * }
17
+ */
18
+ export interface SmsMessage {
19
+ /**
20
+ * Recipient phone number in E.164 format, e.g. '+15551234567'. Optional — it
21
+ * defaults to the notifiable's `phone`, so most notifications omit it.
22
+ */
23
+ to?: string;
24
+ /** Message body text. */
25
+ body: string;
26
+ /**
27
+ * Sender number or alphanumeric ID.
28
+ * Overrides the global `from` in config/notifications.ts when set.
29
+ */
30
+ from?: string;
31
+ }
32
+
33
+ /** An SmsMessage after the recipient has been resolved. */
34
+ interface ResolvedSms extends SmsMessage {
35
+ to: string;
36
+ }
37
+
38
+ /**
39
+ * SMS notification channel — sends via Twilio or Vonage REST APIs.
40
+ *
41
+ * No npm dependencies — uses the global fetch API.
42
+ *
43
+ * Configure in config/notifications.ts:
44
+ * sms: {
45
+ * driver: 'twilio',
46
+ * twilio: {
47
+ * accountSid: Bun.env['TWILIO_ACCOUNT_SID'] ?? '',
48
+ * authToken: Bun.env['TWILIO_AUTH_TOKEN'] ?? '',
49
+ * from: Bun.env['TWILIO_FROM'] ?? '',
50
+ * },
51
+ * }
52
+ *
53
+ * Usage in a Notification:
54
+ * channels() { return ['sms']; }
55
+ * toSms(_notifiable: Notifiable): SmsMessage {
56
+ * return { body: 'Your code is 1234' }; // delivered to notifiable.phone
57
+ * }
58
+ */
59
+ export class SmsChannel {
60
+ constructor(private readonly config: SmsConfigShape) {}
61
+
62
+ async send(notifiable: Notifiable, notification: Notification): Promise<void> {
63
+ const message = await notification.toSms(notifiable);
64
+ const driver = this.config.driver;
65
+
66
+ // Mirror the mail channel: the recipient is the notifiable unless the
67
+ // message names someone else.
68
+ const to = message.to ?? notifiable.routeNotificationFor?.("sms") ?? notifiable.phone;
69
+ if (!to) {
70
+ throw new NotificationChannelUnavailableError(
71
+ `[Zerotal/notifications] No SMS recipient for ${notification.constructor.name}: ` +
72
+ `the notifiable has no 'phone' and toSms() did not set 'to'.`,
73
+ );
74
+ }
75
+ const resolved: ResolvedSms = { ...message, to };
76
+
77
+ if (driver === "twilio") {
78
+ await this._sendTwilio(resolved);
79
+ } else if (driver === "vonage") {
80
+ await this._sendVonage(resolved);
81
+ } else {
82
+ throw new UnknownSmsDriverError(driver);
83
+ }
84
+ }
85
+
86
+ // ── Twilio ──────────────────────────────────────────────────────────────────
87
+
88
+ private async _sendTwilio(message: ResolvedSms): Promise<void> {
89
+ const cfg = this.config.twilio;
90
+ if (!cfg)
91
+ throw new NotificationChannelUnavailableError(
92
+ "[Zerotal/notifications] SMS driver is twilio but config.sms.twilio is not set.",
93
+ );
94
+
95
+ const from = message.from ?? cfg.from;
96
+ const url = `https://api.twilio.com/2010-04-01/Accounts/${cfg.accountSid}/Messages.json`;
97
+
98
+ const res = await fetch(url, {
99
+ method: "POST",
100
+ headers: {
101
+ Authorization: `Basic ${btoa(`${cfg.accountSid}:${cfg.authToken}`)}`,
102
+ "Content-Type": "application/x-www-form-urlencoded",
103
+ },
104
+ body: new URLSearchParams({ From: from, To: message.to, Body: message.body }),
105
+ });
106
+
107
+ if (!res.ok) {
108
+ const body = (await res.json().catch(() => ({}))) as { message?: string };
109
+ throw new NotificationDeliveryError(
110
+ `[Zerotal/notifications] Twilio returned ${res.status}: ${body.message ?? "unknown error"}`,
111
+ );
112
+ }
113
+ }
114
+
115
+ // ── Vonage ──────────────────────────────────────────────────────────────────
116
+
117
+ private async _sendVonage(message: ResolvedSms): Promise<void> {
118
+ const cfg = this.config.vonage;
119
+ if (!cfg)
120
+ throw new NotificationChannelUnavailableError(
121
+ "[Zerotal/notifications] SMS driver is vonage but config.sms.vonage is not set.",
122
+ );
123
+
124
+ const from = message.from ?? cfg.from;
125
+ const res = await fetch("https://rest.nexmo.com/sms/json", {
126
+ method: "POST",
127
+ headers: { "Content-Type": "application/json" },
128
+ body: JSON.stringify({
129
+ api_key: cfg.apiKey,
130
+ api_secret: cfg.apiSecret,
131
+ from,
132
+ to: message.to,
133
+ text: message.body,
134
+ }),
135
+ });
136
+
137
+ if (!res.ok) {
138
+ throw new NotificationDeliveryError(`[Zerotal/notifications] Vonage returned ${res.status}`);
139
+ }
140
+
141
+ const data = (await res.json()) as {
142
+ messages: Array<{ status: string; "error-text"?: string }>;
143
+ };
144
+ const first = data.messages[0];
145
+ if (first && first.status !== "0") {
146
+ throw new NotificationDeliveryError(
147
+ `[Zerotal/notifications] Vonage error: ${first["error-text"] ?? `status ${first.status}`}`,
148
+ );
149
+ }
150
+ }
151
+ }
package/src/admin.ts ADDED
@@ -0,0 +1,219 @@
1
+ /**
2
+ * Notifications → admin panel contribution.
3
+ *
4
+ * When something stops arriving, the question is always the same: did we try,
5
+ * on which channel, and what did the provider say? This console answers that
6
+ * from the delivery counters, and exposes the stored inbox so an operator can
7
+ * see and prune what the database channel has accumulated.
8
+ *
9
+ * The panel's write surface is resolved from the container by binding key and
10
+ * typed through a local structural interface, exactly as the observer bridges in
11
+ * `observability.ts` are: this package depends on `@zerotal/admin` not at all,
12
+ * and an app without the panel pulls in nothing extra — the binding is simply
13
+ * absent and this returns.
14
+ */
15
+ import type { Application } from "@zerotal/core";
16
+ import { channelStats, recentDeliveries } from "./stats.ts";
17
+ import type { NotificationManager } from "./NotificationManager.ts";
18
+
19
+ /** The slice of the admin panel's contribution surface this module uses. */
20
+ interface AdminPanelSink {
21
+ enabled(id: string): boolean;
22
+ console(contribution: ConsoleSpec): void;
23
+ }
24
+
25
+ type Row = Record<string, unknown>;
26
+ type Tone = "primary" | "success" | "muted" | "destructive" | "default";
27
+
28
+ interface ConsoleSpec {
29
+ slug: string;
30
+ title: string;
31
+ ability: string;
32
+ navigationIcon?: string;
33
+ navigationGroup?: string;
34
+ navigationSort?: number;
35
+ navigationBadge?: () => Promise<string | number | null>;
36
+ navigationBadgeColor?: Tone;
37
+ tabs: Array<{
38
+ key: string;
39
+ label: string;
40
+ description?: string;
41
+ columns: Array<{
42
+ key: string;
43
+ label: string;
44
+ align?: "start" | "center" | "end";
45
+ mono?: boolean;
46
+ format?: (value: unknown, row: Row) => string;
47
+ badge?: (value: unknown, row: Row) => Tone | null;
48
+ }>;
49
+ rows: () => Promise<Row[]>;
50
+ rowKey?: string;
51
+ rowActions?: Array<{
52
+ key: string;
53
+ label: string;
54
+ icon?: string;
55
+ danger?: boolean;
56
+ confirm?: string;
57
+ run: (row: Row) => Promise<string | void>;
58
+ }>;
59
+ headerActions?: Array<{
60
+ key: string;
61
+ label: string;
62
+ icon?: string;
63
+ danger?: boolean;
64
+ confirm?: string;
65
+ run: () => Promise<string | void>;
66
+ }>;
67
+ empty?: string;
68
+ badge?: () => Promise<number | null>;
69
+ }>;
70
+ }
71
+
72
+ /** The ability an operator needs to see and act on the notifications console. */
73
+ const ABILITY = "notifications.view";
74
+
75
+ function formatTime(value: unknown): string {
76
+ if (value === null || value === undefined || value === "") return "—";
77
+ const date = typeof value === "number" ? new Date(value) : new Date(String(value));
78
+ return Number.isNaN(date.getTime()) ? String(value) : date.toLocaleString();
79
+ }
80
+
81
+ /** Trim a long provider error down to its first line. */
82
+ function firstLine(value: unknown): string {
83
+ const text = value === null || value === undefined ? "" : String(value);
84
+ const line = text.split("\n")[0]?.trim() ?? "";
85
+ return line.length > 140 ? `${line.slice(0, 137)}…` : line || "—";
86
+ }
87
+
88
+ function formatMs(value: unknown): string {
89
+ const n = Number(value);
90
+ return Number.isFinite(n) ? `${Math.round(n)}ms` : "—";
91
+ }
92
+
93
+ /**
94
+ * Contribute the notifications console to the admin panel, when one is installed.
95
+ *
96
+ * Call from `NotificationProvider.onBooting()` — the panel binds its surface
97
+ * during the registration phase, so it is reachable from any provider's booting
98
+ * phase regardless of the order the two were registered in.
99
+ */
100
+ export function installNotificationsAdmin(app: Application): void {
101
+ const panel = app.container.tryMake("admin.panel" as never) as AdminPanelSink | undefined;
102
+ if (!panel?.enabled("notifications")) return;
103
+
104
+ const manager = (): NotificationManager =>
105
+ app.container.makeSync("notifications") as NotificationManager;
106
+
107
+ panel.console({
108
+ slug: "notifications",
109
+ title: "Notifications",
110
+ ability: ABILITY,
111
+ navigationIcon: "bell",
112
+ navigationGroup: "Operations",
113
+ navigationBadgeColor: "destructive",
114
+ // A failure count is the number worth noticing without looking for it.
115
+ navigationBadge: async () => {
116
+ const failed = recentDeliveries().filter((d) => !d.ok).length;
117
+ return failed > 0 ? failed : null;
118
+ },
119
+ tabs: [
120
+ {
121
+ key: "recent",
122
+ label: "Recent",
123
+ description:
124
+ "Delivery attempts since this process booted, newest first. Not persisted history.",
125
+ empty: "Nothing delivered yet.",
126
+ badge: async () => recentDeliveries().length,
127
+ columns: [
128
+ { key: "at", label: "When", format: formatTime },
129
+ { key: "className", label: "Notification" },
130
+ { key: "channel", label: "Channel", badge: () => "muted" },
131
+ { key: "notifiable", label: "To", mono: true },
132
+ {
133
+ key: "ok",
134
+ label: "Result",
135
+ format: (v) => (v ? "sent" : "failed"),
136
+ badge: (v) => (v ? "success" : "destructive"),
137
+ },
138
+ { key: "durationMs", label: "Took", align: "end", format: formatMs },
139
+ { key: "error", label: "Error", mono: true, format: firstLine },
140
+ ],
141
+ rows: async () =>
142
+ recentDeliveries().map((d) => ({
143
+ at: d.at,
144
+ className: d.className,
145
+ channel: d.channel,
146
+ notifiable: d.notifiable,
147
+ ok: d.ok,
148
+ durationMs: d.durationMs,
149
+ error: d.error ?? "",
150
+ })),
151
+ },
152
+ {
153
+ key: "channels",
154
+ label: "Channels",
155
+ description: "Per-channel totals since boot — where failures are concentrated.",
156
+ empty: "No channel has been used yet.",
157
+ rowKey: "channel",
158
+ columns: [
159
+ { key: "channel", label: "Channel" },
160
+ { key: "sent", label: "Sent", align: "end" },
161
+ {
162
+ key: "failed",
163
+ label: "Failed",
164
+ align: "end",
165
+ badge: (v) => (Number(v) > 0 ? "destructive" : "muted"),
166
+ },
167
+ { key: "avgMs", label: "Avg", align: "end", format: formatMs },
168
+ ],
169
+ rows: async () => channelStats().map((s) => ({ ...s })),
170
+ },
171
+ {
172
+ key: "stored",
173
+ label: "Stored",
174
+ description:
175
+ "Rows written by the database channel, newest first. Pruning deletes read notifications.",
176
+ empty: "No stored notifications.",
177
+ columns: [
178
+ { key: "created_at", label: "When", format: formatTime },
179
+ { key: "type", label: "Notification" },
180
+ { key: "notifiable_type", label: "Recipient type", badge: () => "muted" },
181
+ { key: "notifiable_id", label: "Recipient", mono: true },
182
+ {
183
+ key: "read_at",
184
+ label: "Read",
185
+ format: (v) => (v ? formatTime(v) : "unread"),
186
+ badge: (v) => (v ? "muted" : "primary"),
187
+ },
188
+ ],
189
+ rows: async () => (await manager().database.recent(100)).map((r) => ({ ...r })),
190
+ rowActions: [
191
+ {
192
+ key: "delete",
193
+ label: "Delete",
194
+ icon: "trash",
195
+ danger: true,
196
+ confirm: "Delete this stored notification?",
197
+ run: async (row) => {
198
+ await manager().database.delete(String(row["id"]));
199
+ return "Notification deleted.";
200
+ },
201
+ },
202
+ ],
203
+ headerActions: [
204
+ {
205
+ key: "prune",
206
+ label: "Prune read (30d)",
207
+ icon: "trash",
208
+ danger: true,
209
+ confirm: "Delete read notifications older than 30 days?",
210
+ run: async () => {
211
+ const deleted = await manager().database.prune(30);
212
+ return `${deleted} notification(s) pruned.`;
213
+ },
214
+ },
215
+ ],
216
+ },
217
+ ],
218
+ });
219
+ }
@@ -0,0 +1,54 @@
1
+ import type { Application, FlagDef } from "@zerotal/core";
2
+ import { Command } from "@zerotal/core";
3
+ import type { NotificationManager } from "../NotificationManager.ts";
4
+
5
+ /**
6
+ * `zt notifications:prune` — delete old stored notifications.
7
+ *
8
+ * The database channel never removes anything on its own, so an app that has
9
+ * been notifying users for a year is carrying a year of rows. Run this on a
10
+ * schedule.
11
+ */
12
+ export class NotificationsPruneCommand extends Command {
13
+ static commandName = "notifications:prune";
14
+ static description = "Delete stored notifications older than a given age";
15
+ static needsApp = true;
16
+
17
+ static flags: FlagDef[] = [
18
+ {
19
+ name: "days",
20
+ type: "number",
21
+ description: "Age threshold in days",
22
+ default: 30,
23
+ },
24
+ {
25
+ name: "all",
26
+ type: "boolean",
27
+ description: "Also prune notifications that were never read",
28
+ default: false,
29
+ },
30
+ ];
31
+
32
+ async run(): Promise<void> {
33
+ const app = this.app as Application | undefined;
34
+ if (!app) {
35
+ this.error("Application not available.");
36
+ return;
37
+ }
38
+
39
+ const days = Number(this.flags["days"] ?? 30);
40
+ if (!Number.isFinite(days) || days < 0) {
41
+ this.error(`--days must be a non-negative number, got '${String(this.flags["days"])}'.`);
42
+ return;
43
+ }
44
+
45
+ const includeUnread = this.flags["all"] === true;
46
+ const notifications = app.container.makeSync("notifications") as NotificationManager;
47
+ const deleted = await notifications.database.prune(days, includeUnread);
48
+
49
+ this.info(
50
+ `Pruned ${deleted} notification(s) older than ${days} day(s)` +
51
+ (includeUnread ? ", including unread." : " that had been read."),
52
+ );
53
+ }
54
+ }
@@ -0,0 +1,63 @@
1
+ import type { Application, ArgDef } from "@zerotal/core";
2
+ import { Command } from "@zerotal/core";
3
+ import type { NotificationManager } from "../NotificationManager.ts";
4
+ import { Notification } from "../Notification.ts";
5
+ import { MailMessage } from "../messages/MailMessage.ts";
6
+ import type { Notifiable } from "../types.ts";
7
+
8
+ /** The message `notifications:test` sends. Nothing else references it. */
9
+ class TestNotification extends Notification {
10
+ channels(): string[] {
11
+ return ["mail"];
12
+ }
13
+
14
+ toMail(_notifiable: Notifiable): MailMessage {
15
+ return new MailMessage()
16
+ .subject("Zerotal test notification")
17
+ .greeting("Hello,")
18
+ .line("If you are reading this, your mail configuration works.")
19
+ .line(`Sent at ${new Date().toISOString()}.`)
20
+ .salutation("— Zerotal");
21
+ }
22
+ }
23
+
24
+ /**
25
+ * `zt notifications:test <email>` — send one real email through the configured
26
+ * mail driver.
27
+ *
28
+ * Mail configuration fails in ways unit tests cannot reach: a wrong port, a
29
+ * refused STARTTLS upgrade, credentials the server rejects. This exercises the
30
+ * whole path and prints what the server said.
31
+ */
32
+ export class NotificationsTestCommand extends Command {
33
+ static commandName = "notifications:test";
34
+ static description = "Send a test email through the configured mail driver";
35
+ static needsApp = true;
36
+
37
+ static args: ArgDef[] = [{ name: "email", required: true }];
38
+
39
+ async run(): Promise<void> {
40
+ const app = this.app as Application | undefined;
41
+ if (!app) {
42
+ this.error("Application not available.");
43
+ return;
44
+ }
45
+
46
+ const email = this.args["email"];
47
+ if (!email || !email.includes("@")) {
48
+ this.error(`'${String(email)}' is not an email address.`);
49
+ return;
50
+ }
51
+
52
+ const notifications = app.container.makeSync("notifications") as NotificationManager;
53
+
54
+ this.line(`Sending a test notification to ${email}…`);
55
+ try {
56
+ await notifications.route({ mail: email }).notify(new TestNotification());
57
+ this.info(`Sent. Check ${email} (and the mail driver's own log).`);
58
+ } catch (error) {
59
+ // The provider's own message is the useful part — surface it verbatim.
60
+ this.error(`Send failed: ${error instanceof Error ? error.message : String(error)}`);
61
+ }
62
+ }
63
+ }
@@ -0,0 +1,2 @@
1
+ export { NotificationsPruneCommand } from "./NotificationsPruneCommand.ts";
2
+ export { NotificationsTestCommand } from "./NotificationsTestCommand.ts";