@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/src/config.ts ADDED
@@ -0,0 +1,122 @@
1
+ import { deepMerge } from "@zerotal/core";
2
+ import { NotificationConfigError } from "./errors.ts";
3
+ import type { NotificationConfigShape } from "./types.ts";
4
+
5
+ /** Recursively-optional view of the config, so callers can override just the keys they care about. */
6
+ type DeepPartial<T> = {
7
+ [K in keyof T]?: T[K] extends object ? DeepPartial<T[K]> : T[K];
8
+ };
9
+
10
+ const defaults: NotificationConfigShape = {
11
+ database: {
12
+ table: "notifications",
13
+ },
14
+ mail: {
15
+ driver: "log",
16
+ from: { address: "hello@example.com", name: "Zerotal App" },
17
+ smtp: { host: "localhost", port: 1025, secure: false, username: "", password: "" },
18
+ resend: { apiKey: "" },
19
+ log: { channel: "console" },
20
+ },
21
+ };
22
+
23
+ /**
24
+ * Create a typed notification configuration object with defaults.
25
+ *
26
+ * @example
27
+ * import { NotificationConfig } from '@zerotal/notifications';
28
+ *
29
+ * export default NotificationConfig({
30
+ * database: { table: 'notifications' },
31
+ *
32
+ * // Slack incoming webhook
33
+ * slack: { webhook: Bun.env['SLACK_WEBHOOK'] ?? '' },
34
+ *
35
+ * // SMS via Twilio
36
+ * sms: {
37
+ * driver: 'twilio',
38
+ * twilio: {
39
+ * accountSid: Bun.env['TWILIO_ACCOUNT_SID'] ?? '',
40
+ * authToken: Bun.env['TWILIO_AUTH_TOKEN'] ?? '',
41
+ * from: Bun.env['TWILIO_FROM'] ?? '',
42
+ * },
43
+ * },
44
+ * });
45
+ */
46
+ export function NotificationConfig(
47
+ options: DeepPartial<NotificationConfigShape> = {},
48
+ ): NotificationConfigShape {
49
+ const config = deepMerge(defaults, options as Partial<NotificationConfigShape>);
50
+ validateNotificationConfig(config);
51
+ return config;
52
+ }
53
+
54
+ /**
55
+ * Check the config for combinations that would only fail at send time.
56
+ *
57
+ * A missing Resend key or an SMS driver without its credential block is a
58
+ * deployment mistake, and the cheapest place to notice one is here — at boot,
59
+ * naming the key — rather than on the first password-reset email of the day.
60
+ *
61
+ * @throws {NotificationConfigError} on the first inconsistency found.
62
+ */
63
+ export function validateNotificationConfig(config: NotificationConfigShape): void {
64
+ const { mail, sms } = config;
65
+
66
+ if (!["log", "smtp", "resend"].includes(mail.driver)) {
67
+ throw new NotificationConfigError(
68
+ `Unknown mail driver '${mail.driver}'. Expected 'log', 'smtp', or 'resend'.`,
69
+ { driver: mail.driver },
70
+ );
71
+ }
72
+
73
+ if (mail.driver === "resend" && !mail.resend.apiKey) {
74
+ throw new NotificationConfigError(
75
+ "mail.driver is 'resend' but mail.resend.apiKey is empty. Set RESEND_API_KEY.",
76
+ );
77
+ }
78
+
79
+ if (mail.driver === "smtp") {
80
+ if (!mail.smtp.host) {
81
+ throw new NotificationConfigError("mail.driver is 'smtp' but mail.smtp.host is empty.");
82
+ }
83
+ if (mail.smtp.username && !mail.smtp.password) {
84
+ throw new NotificationConfigError(
85
+ "mail.smtp.username is set but mail.smtp.password is empty.",
86
+ );
87
+ }
88
+ }
89
+
90
+ if (!mail.from.address.includes("@")) {
91
+ throw new NotificationConfigError(
92
+ `mail.from.address '${mail.from.address}' is not an email address.`,
93
+ { address: mail.from.address },
94
+ );
95
+ }
96
+
97
+ if (sms !== undefined) {
98
+ if (sms.driver === "twilio" && !sms.twilio) {
99
+ throw new NotificationConfigError(
100
+ "sms.driver is 'twilio' but sms.twilio is missing. Provide accountSid, authToken, and from.",
101
+ );
102
+ }
103
+ if (sms.driver === "vonage" && !sms.vonage) {
104
+ throw new NotificationConfigError(
105
+ "sms.driver is 'vonage' but sms.vonage is missing. Provide apiKey, apiSecret, and from.",
106
+ );
107
+ }
108
+ if (sms.driver !== "twilio" && sms.driver !== "vonage") {
109
+ throw new NotificationConfigError(
110
+ `Unknown SMS driver '${String(sms.driver)}'. Expected 'twilio' or 'vonage'.`,
111
+ { driver: sms.driver },
112
+ );
113
+ }
114
+ }
115
+ }
116
+
117
+ // Register this package's config namespace for typed config() dot-paths.
118
+ declare module "@zerotal/core" {
119
+ interface ConfigRegistry {
120
+ notifications: NotificationConfigShape;
121
+ }
122
+ }
@@ -0,0 +1,41 @@
1
+ import type { MailDriver, MailPayload } from "./MailDriver.ts";
2
+
3
+ /**
4
+ * Log driver — writes email content to the console or a log file. The safe default
5
+ * for development and tests; no email is actually sent.
6
+ */
7
+ export class LogDriver implements MailDriver {
8
+ constructor(private _channel: "console" | string = "console") {}
9
+
10
+ async send(message: MailPayload): Promise<void> {
11
+ const lines = [
12
+ "────────────────────────────────────",
13
+ `[Mail] ${new Date().toISOString()}`,
14
+ `To: ${message.to.map((a) => (a.name ? `${a.name} <${a.address}>` : a.address)).join(", ")}`,
15
+ `From: ${message.from.name ? `${message.from.name} <${message.from.address}>` : message.from.address}`,
16
+ `Subject: ${message.subject}`,
17
+ ...(message.cc?.length ? [`CC: ${message.cc.map((a) => a.address).join(", ")}`] : []),
18
+ ...(message.attachments?.length
19
+ ? [
20
+ `Files: ${message.attachments
21
+ .map((a) => `${a.filename}${a.inline ? " (inline)" : ""}`)
22
+ .join(", ")}`,
23
+ ]
24
+ : []),
25
+ "",
26
+ ...(message.text ? [`[Text]\n${message.text}`] : []),
27
+ ...(message.html ? [`[HTML]\n${message.html}`] : []),
28
+ "────────────────────────────────────",
29
+ ].join("\n");
30
+
31
+ if (this._channel === "console") {
32
+ console.log(lines);
33
+ return;
34
+ }
35
+
36
+ const existing = await Bun.file(this._channel)
37
+ .text()
38
+ .catch(() => "");
39
+ await Bun.write(this._channel, existing + lines + "\n");
40
+ }
41
+ }
@@ -0,0 +1,54 @@
1
+ /**
2
+ * Mail delivery layer for the notification system's `mail` channel.
3
+ *
4
+ * A `MailDriver` is the transport (log / SMTP / Resend). It receives a fully-resolved
5
+ * `MailPayload` — the wire shape of one email — and delivers it. The fluent
6
+ * {@link MailMessage} a notification returns from `toMail()` is rendered into this
7
+ * payload by `MailChannel`, so drivers never deal with templates or notifiables.
8
+ */
9
+
10
+ export interface MailAddress {
11
+ address: string;
12
+ name?: string;
13
+ }
14
+
15
+ export type AddressInput = string | MailAddress;
16
+
17
+ /** Normalise a string or object address to a `MailAddress`. */
18
+ export function resolveAddress(input: AddressInput): MailAddress {
19
+ return typeof input === "string" ? { address: input } : input;
20
+ }
21
+
22
+ /** A file carried alongside the message body. */
23
+ export interface MailAttachment {
24
+ /** Name the recipient sees. */
25
+ filename: string;
26
+ /** File bytes, or a string for text content. */
27
+ content: string | Uint8Array;
28
+ /** MIME type. Default: `application/octet-stream`. */
29
+ contentType?: string;
30
+ /**
31
+ * Reference the part from the HTML body instead of listing it as a download.
32
+ * Set `cid` too and use `<img src="cid:the-id">`.
33
+ */
34
+ inline?: boolean;
35
+ /** Content-ID for an inline part, without the angle brackets. */
36
+ cid?: string;
37
+ }
38
+
39
+ /** The resolved, ready-to-send shape of a single email. */
40
+ export interface MailPayload {
41
+ to: MailAddress[];
42
+ from: MailAddress;
43
+ subject: string;
44
+ text?: string;
45
+ html?: string;
46
+ cc?: MailAddress[];
47
+ bcc?: MailAddress[];
48
+ replyTo?: MailAddress;
49
+ attachments?: MailAttachment[];
50
+ }
51
+
52
+ export interface MailDriver {
53
+ send(message: MailPayload): Promise<void>;
54
+ }
@@ -0,0 +1,54 @@
1
+ import type { MailDriver, MailPayload } from "./MailDriver.ts";
2
+ import { NotificationDeliveryError } from "../errors.ts";
3
+
4
+ /**
5
+ * Resend driver — sends via https://api.resend.com/emails. Zero npm dependencies
6
+ * (native fetch). Get an API key at https://resend.com (free tier available).
7
+ */
8
+ export class ResendDriver implements MailDriver {
9
+ constructor(private _apiKey: string) {}
10
+
11
+ async send(message: MailPayload): Promise<void> {
12
+ const formatAddr = (a: { address: string; name?: string }) =>
13
+ a.name ? `${a.name} <${a.address}>` : a.address;
14
+
15
+ const body: Record<string, unknown> = {
16
+ from: formatAddr(message.from),
17
+ to: message.to.map(formatAddr),
18
+ subject: message.subject,
19
+ };
20
+
21
+ if (message.cc?.length) body["cc"] = message.cc.map((a) => a.address);
22
+ if (message.bcc?.length) body["bcc"] = message.bcc.map((a) => a.address);
23
+ if (message.replyTo) body["reply_to"] = message.replyTo.address;
24
+ if (message.html !== undefined) body["html"] = message.html;
25
+ if (message.text !== undefined) body["text"] = message.text;
26
+ if (message.attachments?.length) {
27
+ body["attachments"] = message.attachments.map((a) => ({
28
+ filename: a.filename,
29
+ content:
30
+ typeof a.content === "string"
31
+ ? Buffer.from(a.content, "utf8").toString("base64")
32
+ : Buffer.from(a.content).toString("base64"),
33
+ ...(a.contentType ? { content_type: a.contentType } : {}),
34
+ ...(a.cid ? { content_id: a.cid } : {}),
35
+ }));
36
+ }
37
+
38
+ const res = await fetch("https://api.resend.com/emails", {
39
+ method: "POST",
40
+ headers: {
41
+ Authorization: `Bearer ${this._apiKey}`,
42
+ "Content-Type": "application/json",
43
+ },
44
+ body: JSON.stringify(body),
45
+ });
46
+
47
+ if (!res.ok) {
48
+ const err = await res.text().catch(() => "unknown error");
49
+ throw new NotificationDeliveryError(
50
+ `[Zerotal/notifications] Resend API error ${res.status}: ${err}`,
51
+ );
52
+ }
53
+ }
54
+ }