@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
package/src/events.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The notifications package's framework events (mail + notification delivery),
|
|
3
|
+
* emitted on core's {@link FrameworkEvents} bus. Observability packages subscribe
|
|
4
|
+
* to them by kind (their class name).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Emitted after a mail message is sent or queued. `queued` distinguishes an
|
|
9
|
+
* immediate send from a deferred one.
|
|
10
|
+
*
|
|
11
|
+
* @category Mail
|
|
12
|
+
*/
|
|
13
|
+
export class MessageSent {
|
|
14
|
+
constructor(
|
|
15
|
+
readonly className: string,
|
|
16
|
+
readonly to: string[],
|
|
17
|
+
readonly subject: string,
|
|
18
|
+
readonly html: string,
|
|
19
|
+
readonly durationMs: number,
|
|
20
|
+
readonly queued: boolean,
|
|
21
|
+
) {}
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Emitted when a mail message is pushed onto a queue (deferred send).
|
|
26
|
+
* @category Mail
|
|
27
|
+
*/
|
|
28
|
+
export class MessageQueued {
|
|
29
|
+
constructor(
|
|
30
|
+
readonly className: string,
|
|
31
|
+
readonly to: string[],
|
|
32
|
+
readonly subject: string,
|
|
33
|
+
readonly queue: string,
|
|
34
|
+
) {}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Emitted when a mail message fails to send.
|
|
39
|
+
* @category Mail
|
|
40
|
+
*/
|
|
41
|
+
export class MessageFailed {
|
|
42
|
+
constructor(
|
|
43
|
+
readonly className: string,
|
|
44
|
+
readonly to: string[],
|
|
45
|
+
readonly subject: string,
|
|
46
|
+
readonly durationMs: number,
|
|
47
|
+
readonly error: string,
|
|
48
|
+
) {}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Emitted after a notification is delivered (or fails) through one channel —
|
|
53
|
+
* distinct from `MessageSent`, which is mail-specific. One notification dispatched
|
|
54
|
+
* to N channels fires N of these.
|
|
55
|
+
*
|
|
56
|
+
* @category Notifications
|
|
57
|
+
*/
|
|
58
|
+
export class NotificationSent {
|
|
59
|
+
constructor(
|
|
60
|
+
/** The notification class name, e.g. "OrderShippedNotification". */
|
|
61
|
+
readonly className: string,
|
|
62
|
+
/** The channel it went out on: "mail" | "database" | "slack" | "sms" | "broadcast". */
|
|
63
|
+
readonly channel: string,
|
|
64
|
+
/** The recipient identity (email or id). */
|
|
65
|
+
readonly notifiable: string,
|
|
66
|
+
readonly ok: boolean,
|
|
67
|
+
readonly durationMs: number,
|
|
68
|
+
readonly error?: string,
|
|
69
|
+
) {}
|
|
70
|
+
}
|
package/src/global.d.ts
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Ambient declarations specific to this package.
|
|
2
|
+
// Bun, Node (node:*), and bun:test types come from @types/bun (→ bun-types).
|
|
3
|
+
// Only declarations bun-types does NOT provide are kept here.
|
|
4
|
+
|
|
5
|
+
// ── Bun globals ───────────────────────────────────────────────────────────
|
|
6
|
+
interface Request {
|
|
7
|
+
readonly params?: Record<string, string>;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// ── SQLInstance ───────────────────────────────────────────────────────────
|
|
11
|
+
interface SQLInstance {
|
|
12
|
+
<T = Record<string, unknown>>(
|
|
13
|
+
strings: TemplateStringsArray,
|
|
14
|
+
...values: unknown[]
|
|
15
|
+
): Promise<T[]>;
|
|
16
|
+
begin<T>(fn: (tx: SQLInstance) => Promise<T>): Promise<T>;
|
|
17
|
+
end(): Promise<void>;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface RedisInstance {
|
|
21
|
+
get(key: string): Promise<string | null>;
|
|
22
|
+
set(key: string, value: string): Promise<void>;
|
|
23
|
+
set(key: string, value: string, options: { ex: number }): Promise<void>;
|
|
24
|
+
lpush(key: string, value: string): Promise<number>;
|
|
25
|
+
rpop(key: string): Promise<string | null>;
|
|
26
|
+
llen(key: string): Promise<number>;
|
|
27
|
+
del(...keys: string[]): Promise<number>;
|
|
28
|
+
expire(key: string, seconds: number): Promise<number>;
|
|
29
|
+
keys(pattern: string): Promise<string[]>;
|
|
30
|
+
flushdb(): Promise<void>;
|
|
31
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
export { Notification } from "./Notification.ts";
|
|
2
|
+
// `Notifiable` is exported here as BOTH the mixin (value) and the contract (type) — a
|
|
3
|
+
// function+interface merge in Notifiable.ts.
|
|
4
|
+
export { Notifiable } from "./Notifiable.ts";
|
|
5
|
+
export { NotificationManager } from "./NotificationManager.ts";
|
|
6
|
+
export { NotificationFake } from "./NotificationFake.ts";
|
|
7
|
+
export { NotificationProvider } from "./provider/NotificationProvider.ts";
|
|
8
|
+
export { Notify } from "./facades/Notify.ts";
|
|
9
|
+
export { NotificationConfig, validateNotificationConfig } from "./config.ts";
|
|
10
|
+
|
|
11
|
+
// Channels
|
|
12
|
+
export { MailChannel } from "./MailChannel.ts";
|
|
13
|
+
export { DatabaseChannel } from "./DatabaseChannel.ts";
|
|
14
|
+
export { SlackChannel } from "./SlackChannel.ts";
|
|
15
|
+
export { SmsChannel } from "./SmsChannel.ts";
|
|
16
|
+
export { BroadcastChannel, BROADCAST_NOTIFICATION_EVENT } from "./BroadcastChannel.ts";
|
|
17
|
+
|
|
18
|
+
// Per-channel messages
|
|
19
|
+
export { MailMessage, RichLine } from "./messages/MailMessage.ts";
|
|
20
|
+
export { BroadcastMessage } from "./BroadcastMessage.ts";
|
|
21
|
+
|
|
22
|
+
// On-demand (model-less) recipients
|
|
23
|
+
export { OnDemandNotifiable } from "./OnDemandNotifiable.ts";
|
|
24
|
+
export type { OnDemandRoutes } from "./OnDemandNotifiable.ts";
|
|
25
|
+
|
|
26
|
+
// Queue serialization — a notification outside app/notifications/ registers here.
|
|
27
|
+
export { NotificationRegistry } from "./NotificationRegistry.ts";
|
|
28
|
+
export type { NotificationClass } from "./NotificationRegistry.ts";
|
|
29
|
+
|
|
30
|
+
// Mail drivers (for the mail channel) — swap or supply your own transport.
|
|
31
|
+
export { LogDriver } from "./drivers/LogDriver.ts";
|
|
32
|
+
export { SmtpDriver } from "./drivers/SmtpDriver.ts";
|
|
33
|
+
export { ResendDriver } from "./drivers/ResendDriver.ts";
|
|
34
|
+
export type {
|
|
35
|
+
MailDriver,
|
|
36
|
+
MailPayload,
|
|
37
|
+
MailAddress,
|
|
38
|
+
MailAttachment,
|
|
39
|
+
AddressInput,
|
|
40
|
+
} from "./drivers/MailDriver.ts";
|
|
41
|
+
|
|
42
|
+
export { SendNotificationJob } from "./SendNotificationJob.ts";
|
|
43
|
+
export { BroadcastNotificationJob } from "./BroadcastNotificationJob.ts";
|
|
44
|
+
export type {
|
|
45
|
+
NotificationConfigShape,
|
|
46
|
+
MailConfigShape,
|
|
47
|
+
SmsConfigShape,
|
|
48
|
+
TwilioConfigShape,
|
|
49
|
+
VonageConfigShape,
|
|
50
|
+
NotificationChannel,
|
|
51
|
+
} from "./types.ts";
|
|
52
|
+
export type { NotificationRecord, InboxQuery } from "./DatabaseChannel.ts";
|
|
53
|
+
export type { SlackMessage } from "./SlackChannel.ts";
|
|
54
|
+
export type { SmsMessage } from "./SmsChannel.ts";
|
|
55
|
+
export type { TextStyle } from "./messages/MailMessage.ts";
|
|
56
|
+
|
|
57
|
+
// Delivery counters backing the admin console.
|
|
58
|
+
export { recentDeliveries, channelStats } from "./stats.ts";
|
|
59
|
+
export type { RecentDelivery, ChannelStat } from "./stats.ts";
|
|
60
|
+
|
|
61
|
+
// Console commands
|
|
62
|
+
export { NotificationsPruneCommand, NotificationsTestCommand } from "./commands/index.ts";
|
|
63
|
+
|
|
64
|
+
// Typed error vocabulary
|
|
65
|
+
export * from "./errors.ts";
|
|
66
|
+
|
|
67
|
+
// Framework instrumentation events (emitted on the core FrameworkEvents bus)
|
|
68
|
+
export { MessageSent, MessageQueued, MessageFailed, NotificationSent } from "./events.ts";
|
|
@@ -0,0 +1,342 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolveAddress,
|
|
3
|
+
type AddressInput,
|
|
4
|
+
type MailAddress,
|
|
5
|
+
type MailAttachment,
|
|
6
|
+
type MailPayload,
|
|
7
|
+
} from "../drivers/MailDriver.ts";
|
|
8
|
+
|
|
9
|
+
/** Inline text styling applied to a whole line or to a single run within a line. */
|
|
10
|
+
export interface TextStyle {
|
|
11
|
+
bold?: boolean;
|
|
12
|
+
italic?: boolean;
|
|
13
|
+
/** Any CSS color, e.g. "#dc2626" or "red". */
|
|
14
|
+
color?: string;
|
|
15
|
+
/** Font size in pixels. */
|
|
16
|
+
size?: number;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** One styled run of text. */
|
|
20
|
+
interface TextSegment {
|
|
21
|
+
text: string;
|
|
22
|
+
style?: TextStyle;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** A line is one or more styled runs rendered into a single paragraph. */
|
|
26
|
+
type Line = TextSegment[];
|
|
27
|
+
|
|
28
|
+
const escapeHtml = (s: string): string =>
|
|
29
|
+
s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
30
|
+
|
|
31
|
+
/** Escape a value placed inside a double-quoted HTML attribute (prevents breakout). */
|
|
32
|
+
const escapeAttribute = (s: string): string =>
|
|
33
|
+
s.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<").replace(/>/g, ">");
|
|
34
|
+
|
|
35
|
+
const styleToCss = (style?: TextStyle): string => {
|
|
36
|
+
if (!style) return "";
|
|
37
|
+
const css: string[] = [];
|
|
38
|
+
if (style.bold) css.push("font-weight:600");
|
|
39
|
+
if (style.italic) css.push("font-style:italic");
|
|
40
|
+
if (style.color) css.push(`color:${style.color}`);
|
|
41
|
+
if (style.size) css.push(`font-size:${style.size}px`);
|
|
42
|
+
return css.join(";");
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Fluent builder for a single line composed of differently-styled runs of text.
|
|
47
|
+
* Passed to the callback form of `MailMessage.line()`.
|
|
48
|
+
*
|
|
49
|
+
* @example
|
|
50
|
+
* .line((t) => t
|
|
51
|
+
* .text("Your order ")
|
|
52
|
+
* .bold("#1234")
|
|
53
|
+
* .text(" is ")
|
|
54
|
+
* .color("on its way", "#16a34a"))
|
|
55
|
+
*/
|
|
56
|
+
export class RichLine {
|
|
57
|
+
/** @internal Collected runs, read by MailMessage during rendering. */
|
|
58
|
+
readonly segments: TextSegment[] = [];
|
|
59
|
+
|
|
60
|
+
/** Append an unstyled run. */
|
|
61
|
+
text(content: string): this {
|
|
62
|
+
this.segments.push({ text: content });
|
|
63
|
+
return this;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** Append a bold run. */
|
|
67
|
+
bold(content: string): this {
|
|
68
|
+
this.segments.push({ text: content, style: { bold: true } });
|
|
69
|
+
return this;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Append an italic run. */
|
|
73
|
+
italic(content: string): this {
|
|
74
|
+
this.segments.push({ text: content, style: { italic: true } });
|
|
75
|
+
return this;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Append a coloured run. `color` is any CSS color. */
|
|
79
|
+
color(content: string, color: string): this {
|
|
80
|
+
this.segments.push({ text: content, style: { color } });
|
|
81
|
+
return this;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Append a run with an explicit font size in pixels. */
|
|
85
|
+
size(content: string, px: number): this {
|
|
86
|
+
this.segments.push({ text: content, style: { size: px } });
|
|
87
|
+
return this;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Append a run with an arbitrary combination of styles. */
|
|
91
|
+
styled(content: string, style: TextStyle): this {
|
|
92
|
+
this.segments.push({ text: content, style });
|
|
93
|
+
return this;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* The fluent representation a notification returns from `toMail()` — symmetric with
|
|
99
|
+
* `toSms() → SmsMessage` and `toSlack() → SlackMessage`. Compose a branded email from
|
|
100
|
+
* a greeting, lines, and an optional call-to-action button, or drop to `.html()` for a
|
|
101
|
+
* fully custom body.
|
|
102
|
+
*
|
|
103
|
+
* Lines accept inline styling. Pass a `TextStyle` to style a whole line, or use the
|
|
104
|
+
* callback form to mix differently-styled runs within one line.
|
|
105
|
+
*
|
|
106
|
+
* The recipient is normally the notifiable (its `email`), so you don't set `to()` —
|
|
107
|
+
* `MailChannel` fills it in. Use `to()`/`cc()`/`bcc()`/`from()`/`replyTo()` only to
|
|
108
|
+
* override.
|
|
109
|
+
*
|
|
110
|
+
* @example
|
|
111
|
+
* toMail(n: Notifiable): MailMessage {
|
|
112
|
+
* return new MailMessage()
|
|
113
|
+
* .subject("Your order shipped")
|
|
114
|
+
* .greeting(`Hi ${n.name ?? "there"},`, { bold: true })
|
|
115
|
+
* .line("Your order is on its way.")
|
|
116
|
+
* .line("Action required", { bold: true, color: "#dc2626", size: 18 })
|
|
117
|
+
* .line((t) => t.text("Tracking number: ").bold(this.order.tracking))
|
|
118
|
+
* .action("Track package", `https://app.test/orders/${this.order.id}`)
|
|
119
|
+
* .line("Thanks for shopping with us!");
|
|
120
|
+
* }
|
|
121
|
+
*/
|
|
122
|
+
export class MailMessage {
|
|
123
|
+
private _subject = "";
|
|
124
|
+
private _greeting?: Line;
|
|
125
|
+
private _salutation?: Line;
|
|
126
|
+
private _introLines: Line[] = [];
|
|
127
|
+
private _outroLines: Line[] = [];
|
|
128
|
+
private _actionText?: string;
|
|
129
|
+
private _actionUrl?: string;
|
|
130
|
+
private _html?: string;
|
|
131
|
+
private _text?: string;
|
|
132
|
+
private _to: MailAddress[] = [];
|
|
133
|
+
private _cc: MailAddress[] = [];
|
|
134
|
+
private _bcc: MailAddress[] = [];
|
|
135
|
+
private _from?: MailAddress;
|
|
136
|
+
private _replyTo?: MailAddress;
|
|
137
|
+
private _attachments: MailAttachment[] = [];
|
|
138
|
+
|
|
139
|
+
subject(text: string): this {
|
|
140
|
+
this._subject = text;
|
|
141
|
+
return this;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Opening line, e.g. "Hi Ada,". Defaults to "Hello," when omitted. Accepts the same
|
|
146
|
+
* styling forms as `line()`: a plain string, a string + `TextStyle`, or a `RichLine`
|
|
147
|
+
* callback for mixed runs.
|
|
148
|
+
*/
|
|
149
|
+
greeting(text: string): this;
|
|
150
|
+
greeting(text: string, style: TextStyle): this;
|
|
151
|
+
greeting(build: (t: RichLine) => void): this;
|
|
152
|
+
greeting(arg: string | ((t: RichLine) => void), style?: TextStyle): this {
|
|
153
|
+
this._greeting = this._toLine(arg, style);
|
|
154
|
+
return this;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Closing line, e.g. "Regards,". Defaults to "Regards,". Accepts the same styling
|
|
159
|
+
* forms as `line()`.
|
|
160
|
+
*/
|
|
161
|
+
salutation(text: string): this;
|
|
162
|
+
salutation(text: string, style: TextStyle): this;
|
|
163
|
+
salutation(build: (t: RichLine) => void): this;
|
|
164
|
+
salutation(arg: string | ((t: RichLine) => void), style?: TextStyle): this {
|
|
165
|
+
this._salutation = this._toLine(arg, style);
|
|
166
|
+
return this;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Add a paragraph. Lines before `action()` render above the button; lines after, below it.
|
|
171
|
+
*
|
|
172
|
+
* - Plain: `.line("Welcome aboard.")`
|
|
173
|
+
* - Whole-line style: `.line("Heads up", { bold: true, color: "#dc2626", size: 18 })`
|
|
174
|
+
* - Mixed runs: `.line((t) => t.text("Code: ").bold("AB12").color(" (expires soon)", "#6b7280"))`
|
|
175
|
+
*/
|
|
176
|
+
line(text: string): this;
|
|
177
|
+
line(text: string, style: TextStyle): this;
|
|
178
|
+
line(build: (t: RichLine) => void): this;
|
|
179
|
+
line(arg: string | ((t: RichLine) => void), style?: TextStyle): this {
|
|
180
|
+
const segments = this._toLine(arg, style);
|
|
181
|
+
if (this._actionText) this._outroLines.push(segments);
|
|
182
|
+
else this._introLines.push(segments);
|
|
183
|
+
return this;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** A call-to-action button. At most one per message. */
|
|
187
|
+
action(text: string, url: string): this {
|
|
188
|
+
this._actionText = text;
|
|
189
|
+
this._actionUrl = url;
|
|
190
|
+
return this;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/** Provide a fully custom HTML body, bypassing the greeting/line/action template. */
|
|
194
|
+
html(content: string): this {
|
|
195
|
+
this._html = content;
|
|
196
|
+
return this;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Provide an explicit plain-text body (otherwise one is derived from the lines). */
|
|
200
|
+
text(content: string): this {
|
|
201
|
+
this._text = content;
|
|
202
|
+
return this;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
to(address: AddressInput | AddressInput[]): this {
|
|
206
|
+
return this._push(this._to, address);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
cc(address: AddressInput | AddressInput[]): this {
|
|
210
|
+
return this._push(this._cc, address);
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
bcc(address: AddressInput | AddressInput[]): this {
|
|
214
|
+
return this._push(this._bcc, address);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
from(address: AddressInput): this {
|
|
218
|
+
this._from = resolveAddress(address);
|
|
219
|
+
return this;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
replyTo(address: AddressInput): this {
|
|
223
|
+
this._replyTo = resolveAddress(address);
|
|
224
|
+
return this;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Attach a file to the message.
|
|
229
|
+
*
|
|
230
|
+
* @example
|
|
231
|
+
* .attach({ filename: "invoice.pdf", content: pdfBytes, contentType: "application/pdf" })
|
|
232
|
+
*/
|
|
233
|
+
attach(attachment: MailAttachment): this {
|
|
234
|
+
this._attachments.push(attachment);
|
|
235
|
+
return this;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Attach a file read from disk. Reads the bytes when called, so the message is
|
|
240
|
+
* self-contained by the time a driver sends it.
|
|
241
|
+
*
|
|
242
|
+
* @example
|
|
243
|
+
* await new MailMessage().subject("Your invoice").attachFile("./storage/invoice.pdf");
|
|
244
|
+
*/
|
|
245
|
+
async attachFile(path: string, options: { filename?: string; contentType?: string } = {}) {
|
|
246
|
+
const file = Bun.file(path);
|
|
247
|
+
return this.attach({
|
|
248
|
+
filename: options.filename ?? path.split(/[\\/]/).pop() ?? "attachment",
|
|
249
|
+
content: new Uint8Array(await file.arrayBuffer()),
|
|
250
|
+
contentType: options.contentType ?? file.type ?? "application/octet-stream",
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Embed an image referenced from the HTML body as `<img src="cid:the-id">`,
|
|
256
|
+
* rather than listing it as a download.
|
|
257
|
+
*/
|
|
258
|
+
embed(cid: string, attachment: Omit<MailAttachment, "cid" | "inline">): this {
|
|
259
|
+
return this.attach({ ...attachment, cid, inline: true });
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
private _push(target: MailAddress[], address: AddressInput | AddressInput[]): this {
|
|
263
|
+
const arr = Array.isArray(address) ? address : [address];
|
|
264
|
+
target.push(...arr.map(resolveAddress));
|
|
265
|
+
return this;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
private _toLine(arg: string | ((t: RichLine) => void), style?: TextStyle): Line {
|
|
269
|
+
if (typeof arg === "function") {
|
|
270
|
+
const rich = new RichLine();
|
|
271
|
+
arg(rich);
|
|
272
|
+
return rich.segments;
|
|
273
|
+
}
|
|
274
|
+
return [{ text: arg, ...(style ? { style } : {}) }];
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Resolve to the wire payload a driver sends. `defaultFrom` and `fallbackTo` come from
|
|
279
|
+
* MailChannel (config default sender + the notifiable's address).
|
|
280
|
+
*/
|
|
281
|
+
toPayload(defaultFrom: MailAddress, fallbackTo: MailAddress[]): MailPayload {
|
|
282
|
+
const html = this._html ?? this._renderHtml();
|
|
283
|
+
const text = this._text ?? this._renderText();
|
|
284
|
+
return {
|
|
285
|
+
to: this._to.length > 0 ? this._to : fallbackTo,
|
|
286
|
+
from: this._from ?? defaultFrom,
|
|
287
|
+
subject: this._subject,
|
|
288
|
+
...(html ? { html } : {}),
|
|
289
|
+
...(text ? { text } : {}),
|
|
290
|
+
...(this._cc.length > 0 ? { cc: this._cc } : {}),
|
|
291
|
+
...(this._bcc.length > 0 ? { bcc: this._bcc } : {}),
|
|
292
|
+
...(this._replyTo ? { replyTo: this._replyTo } : {}),
|
|
293
|
+
...(this._attachments.length > 0 ? { attachments: this._attachments } : {}),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// ── Rendering ──────────────────────────────────────────────────────────────
|
|
298
|
+
|
|
299
|
+
private _renderHtml(): string {
|
|
300
|
+
const parts: string[] = [];
|
|
301
|
+
parts.push(this._paragraph(this._greeting ?? [{ text: "Hello," }]));
|
|
302
|
+
for (const l of this._introLines) parts.push(this._paragraph(l));
|
|
303
|
+
if (this._actionText && this._actionUrl) {
|
|
304
|
+
parts.push(
|
|
305
|
+
`<p style="margin:24px 0"><a href="${escapeHtml(this._actionUrl)}" ` +
|
|
306
|
+
`style="display:inline-block;background:#4f46e5;color:#fff;text-decoration:none;` +
|
|
307
|
+
`padding:12px 20px;border-radius:8px;font-size:14px;font-weight:600">${escapeHtml(this._actionText)}</a></p>`,
|
|
308
|
+
);
|
|
309
|
+
}
|
|
310
|
+
for (const l of this._outroLines) parts.push(this._paragraph(l));
|
|
311
|
+
parts.push(this._paragraph(this._salutation ?? [{ text: "Regards," }]));
|
|
312
|
+
|
|
313
|
+
return (
|
|
314
|
+
`<div style="max-width:560px;margin:0 auto;padding:32px 24px;font-family:` +
|
|
315
|
+
`-apple-system,Segoe UI,Roboto,Helvetica,Arial,sans-serif">${parts.join("")}</div>`
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private _paragraph(line: Line): string {
|
|
320
|
+
const inner = line.map((seg) => this._span(seg)).join("");
|
|
321
|
+
return `<p style="margin:0 0 16px;font-size:15px;line-height:1.6;color:#374151">${inner}</p>`;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
private _span(seg: TextSegment): string {
|
|
325
|
+
const text = escapeHtml(seg.text);
|
|
326
|
+
const css = styleToCss(seg.style);
|
|
327
|
+
return css ? `<span style="${escapeAttribute(css)}">${text}</span>` : text;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
private _renderText(): string {
|
|
331
|
+
const parts: string[] = [this._lineText(this._greeting ?? [{ text: "Hello," }])];
|
|
332
|
+
for (const l of this._introLines) parts.push(this._lineText(l));
|
|
333
|
+
if (this._actionText && this._actionUrl) parts.push(`${this._actionText}: ${this._actionUrl}`);
|
|
334
|
+
for (const l of this._outroLines) parts.push(this._lineText(l));
|
|
335
|
+
parts.push(this._lineText(this._salutation ?? [{ text: "Regards," }]));
|
|
336
|
+
return parts.join("\n\n");
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
private _lineText(line: Line): string {
|
|
340
|
+
return line.map((seg) => seg.text).join("");
|
|
341
|
+
}
|
|
342
|
+
}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Notifications → observer bridges. The mail/notification stack emits its own
|
|
3
|
+
* `MessageSent` / `MessageQueued` / `MessageFailed` / `NotificationSent` framework
|
|
4
|
+
* events on the core `FrameworkEvents` bus; this module forwards them to whichever
|
|
5
|
+
* observer packages are installed.
|
|
6
|
+
*
|
|
7
|
+
* Each observer's write surface is resolved from the container by binding key and
|
|
8
|
+
* typed through a local structural interface, so this package depends on none of
|
|
9
|
+
* the observer packages — installing or removing an observer requires no change here.
|
|
10
|
+
*/
|
|
11
|
+
import { FrameworkEvents, RequestContext } from "@zerotal/core";
|
|
12
|
+
import type { Application } from "@zerotal/core";
|
|
13
|
+
import { MessageSent, MessageQueued, MessageFailed, NotificationSent } from "./events.ts";
|
|
14
|
+
|
|
15
|
+
/** The subset of the monitor store this bridge calls (bound as `monitor.store`). */
|
|
16
|
+
interface MonitorSink {
|
|
17
|
+
recordMail(m: {
|
|
18
|
+
subject: string;
|
|
19
|
+
to: string;
|
|
20
|
+
mailer: string;
|
|
21
|
+
status: "sent" | "queued" | "failed";
|
|
22
|
+
ms: number;
|
|
23
|
+
body: string;
|
|
24
|
+
}): void;
|
|
25
|
+
recordEvent(e: {
|
|
26
|
+
kind: string;
|
|
27
|
+
label: string;
|
|
28
|
+
status?: "ok" | "warn" | "bad" | "info";
|
|
29
|
+
route?: string | null;
|
|
30
|
+
data?: Record<string, unknown>;
|
|
31
|
+
}): void;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The subset of the devtools trace sink this bridge calls (bound as `devtools.trace`). */
|
|
35
|
+
interface DevtoolsSink {
|
|
36
|
+
bufferMail(
|
|
37
|
+
ctx: object,
|
|
38
|
+
m: {
|
|
39
|
+
className: string;
|
|
40
|
+
to: string[];
|
|
41
|
+
subject: string;
|
|
42
|
+
html: string;
|
|
43
|
+
durationMs: number;
|
|
44
|
+
queued: boolean;
|
|
45
|
+
},
|
|
46
|
+
): void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** The subset of the logger this bridge calls (bound as `log`). */
|
|
50
|
+
interface LogSink {
|
|
51
|
+
error(message: string, context?: Record<string, unknown>, error?: unknown): void;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Subscribe the notification events to every installed observer. Returns a disposer
|
|
56
|
+
* that removes every subscription; call it from the provider's `onStopping()`.
|
|
57
|
+
*/
|
|
58
|
+
export function installNotificationsObservability(app: Application): () => void {
|
|
59
|
+
const unsubs: Array<() => void> = [];
|
|
60
|
+
|
|
61
|
+
const store = app.container.tryMake("monitor.store" as never) as MonitorSink | undefined;
|
|
62
|
+
if (store) {
|
|
63
|
+
unsubs.push(
|
|
64
|
+
FrameworkEvents.on(MessageSent, (e) =>
|
|
65
|
+
store.recordMail({
|
|
66
|
+
subject: e.subject,
|
|
67
|
+
to: e.to.join(", "),
|
|
68
|
+
mailer: e.className,
|
|
69
|
+
status: e.queued ? "queued" : "sent",
|
|
70
|
+
ms: Math.round(e.durationMs),
|
|
71
|
+
body: e.html,
|
|
72
|
+
}),
|
|
73
|
+
),
|
|
74
|
+
FrameworkEvents.on(MessageFailed, (e) =>
|
|
75
|
+
store.recordMail({
|
|
76
|
+
subject: e.subject,
|
|
77
|
+
to: e.to.join(", "),
|
|
78
|
+
mailer: e.className,
|
|
79
|
+
status: "failed",
|
|
80
|
+
ms: Math.round(e.durationMs),
|
|
81
|
+
body: e.error,
|
|
82
|
+
}),
|
|
83
|
+
),
|
|
84
|
+
FrameworkEvents.on(MessageQueued, (e) =>
|
|
85
|
+
store.recordMail({
|
|
86
|
+
subject: e.subject,
|
|
87
|
+
to: e.to.join(", "),
|
|
88
|
+
mailer: e.className,
|
|
89
|
+
status: "queued",
|
|
90
|
+
ms: 0,
|
|
91
|
+
body: `Queued for delivery on "${e.queue}".`,
|
|
92
|
+
}),
|
|
93
|
+
),
|
|
94
|
+
FrameworkEvents.on(NotificationSent, (e) =>
|
|
95
|
+
store.recordEvent({
|
|
96
|
+
kind: "notification",
|
|
97
|
+
label: e.className,
|
|
98
|
+
status: e.ok ? "ok" : "bad",
|
|
99
|
+
route: e.channel,
|
|
100
|
+
data: {
|
|
101
|
+
channel: e.channel,
|
|
102
|
+
to: e.notifiable,
|
|
103
|
+
ms: Math.round(e.durationMs),
|
|
104
|
+
detail: e.error ?? e.notifiable,
|
|
105
|
+
},
|
|
106
|
+
}),
|
|
107
|
+
),
|
|
108
|
+
);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const trace = app.container.tryMake("devtools.trace" as never) as DevtoolsSink | undefined;
|
|
112
|
+
if (trace) {
|
|
113
|
+
unsubs.push(
|
|
114
|
+
// Mail happens outside the HTTP frame, so correlate to the in-flight request.
|
|
115
|
+
FrameworkEvents.on(MessageSent, (e) => {
|
|
116
|
+
const ctx = RequestContext.tryGet();
|
|
117
|
+
if (!ctx) return;
|
|
118
|
+
trace.bufferMail(ctx, {
|
|
119
|
+
className: e.className,
|
|
120
|
+
to: e.to,
|
|
121
|
+
subject: e.subject,
|
|
122
|
+
html: e.html,
|
|
123
|
+
durationMs: e.durationMs,
|
|
124
|
+
queued: e.queued,
|
|
125
|
+
});
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const log = app.container.tryMake("log" as never) as LogSink | undefined;
|
|
131
|
+
if (log) {
|
|
132
|
+
unsubs.push(
|
|
133
|
+
FrameworkEvents.on(MessageFailed, (e) =>
|
|
134
|
+
log.error(
|
|
135
|
+
"Mail send failed",
|
|
136
|
+
{ className: e.className, to: e.to, subject: e.subject, durationMs: e.durationMs },
|
|
137
|
+
new Error(e.error),
|
|
138
|
+
),
|
|
139
|
+
),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return () => {
|
|
144
|
+
for (const unsub of unsubs) unsub();
|
|
145
|
+
};
|
|
146
|
+
}
|