@zerotal/notifications 1.6.3 → 1.7.2

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/api-surface.md ADDED
@@ -0,0 +1,537 @@
1
+ # @zerotal/notifications — public API surface
2
+
3
+ <!-- AUTO-GENERATED by scripts/api-surface.ts. Do not edit by hand.
4
+ Run `bun run api:surface` to regenerate after an intentional API change. -->
5
+
6
+ ## . `(./src/index.ts)`
7
+
8
+ class BroadcastChannel = {
9
+ new (): BroadcastChannel
10
+ channelFor: (notifiable: Notifiable) => string
11
+ send: (notifiable: Notifiable, notification: Notification) => Promise<void>
12
+ }
13
+
14
+ class BroadcastMessage = {
15
+ new (data: Record<string, unknown>): BroadcastMessage
16
+ onQueue: (queue: string) => BroadcastMessage
17
+ queue: string | undefined
18
+ readonly data: Record<string, unknown>
19
+ }
20
+
21
+ class BroadcastNotificationJob = {
22
+ new (_channel?: string, _event?: string, _data?: Record<string, unknown>, queue?: string): BroadcastNotificationJob
23
+ static fromPayload: (data: Record<string, unknown>) => BroadcastNotificationJob
24
+ _chain: SerializedJob[] | undefined
25
+ batchId: string | undefined
26
+ className: string
27
+ debounceKey: () => string
28
+ handle: () => Promise<void>
29
+ payload: () => Record<string, unknown>
30
+ readonly debounce?: number
31
+ readonly maxAttempts: number
32
+ readonly queue: string
33
+ readonly retryDelay: number
34
+ }
35
+
36
+ class DatabaseChannel = {
37
+ new (table: string, _sql: SQLInstance): DatabaseChannel
38
+ _count: () => Promise<number>
39
+ all: (notifiable: Notifiable, query?: InboxQuery) => Promise<NotificationRecord[]>
40
+ clear: (notifiable: Notifiable) => Promise<void>
41
+ delete: (id: string) => Promise<void>
42
+ markAllAsRead: (notifiable: Notifiable) => Promise<void>
43
+ markAsRead: (id: string) => Promise<void>
44
+ markAsUnread: (id: string) => Promise<void>
45
+ prune: (days: number, includeUnread?: boolean) => Promise<number>
46
+ recent: (limit?: number) => Promise<NotificationRecord[]>
47
+ send: (notifiable: Notifiable, notification: Notification) => Promise<void>
48
+ unread: (notifiable: Notifiable, query?: InboxQuery) => Promise<NotificationRecord[]>
49
+ unreadCount: (notifiable: Notifiable) => Promise<number>
50
+ }
51
+
52
+ class LogDriver = {
53
+ new (_channel?: 'console' | string): LogDriver
54
+ send: (message: MailPayload) => Promise<void>
55
+ }
56
+
57
+ class MailChannel = {
58
+ new (config: MailConfigShape): MailChannel
59
+ send: (notifiable: Notifiable, notification: Notification) => Promise<void>
60
+ }
61
+
62
+ class MailMessage = {
63
+ new (): MailMessage
64
+ action: (text: string, url: string) => MailMessage
65
+ attach: (attachment: MailAttachment) => MailMessage
66
+ attachFile: (path: string, options?: { filename?: string; contentType?: string;}) => Promise<MailMessage>
67
+ bcc: (address: AddressInput | AddressInput[]) => MailMessage
68
+ cc: (address: AddressInput | AddressInput[]) => MailMessage
69
+ embed: (cid: string, attachment: Omit<MailAttachment, 'cid' | 'inline'>) => MailMessage
70
+ from: (address: AddressInput) => MailMessage
71
+ greeting: { (text: string): MailMessage; (text: string, style: TextStyle): MailMessage; (build: (t: RichLine) => void): MailMessage;}
72
+ html: (content: string) => MailMessage
73
+ line: { (text: string): MailMessage; (text: string, style: TextStyle): MailMessage; (build: (t: RichLine) => void): MailMessage;}
74
+ replyTo: (address: AddressInput) => MailMessage
75
+ salutation: { (text: string): MailMessage; (text: string, style: TextStyle): MailMessage; (build: (t: RichLine) => void): MailMessage;}
76
+ subject: (text: string) => MailMessage
77
+ text: (content: string) => MailMessage
78
+ to: (address: AddressInput | AddressInput[]) => MailMessage
79
+ toPayload: (defaultFrom: MailAddress, fallbackTo: MailAddress[]) => MailPayload
80
+ }
81
+
82
+ class MessageFailed = {
83
+ new (className: string, to: string[], subject: string, durationMs: number, error: string): MessageFailed
84
+ readonly className: string
85
+ readonly durationMs: number
86
+ readonly error: string
87
+ readonly subject: string
88
+ readonly to: string[]
89
+ }
90
+
91
+ class MessageQueued = {
92
+ new (className: string, to: string[], subject: string, queue: string): MessageQueued
93
+ readonly className: string
94
+ readonly queue: string
95
+ readonly subject: string
96
+ readonly to: string[]
97
+ }
98
+
99
+ class MessageSent = {
100
+ new (className: string, to: string[], subject: string, html: string, durationMs: number, queued: boolean): MessageSent
101
+ readonly className: string
102
+ readonly durationMs: number
103
+ readonly html: string
104
+ readonly queued: boolean
105
+ readonly subject: string
106
+ readonly to: string[]
107
+ }
108
+
109
+ class Notification = {
110
+ new (): Notification
111
+ broadcastType: () => string
112
+ channels: (notifiable?: Notifiable) => string[]
113
+ payload: () => Record<string, unknown>
114
+ toBroadcast: (_notifiable: Notifiable) => BroadcastMessage | Record<string, unknown> | Promise<BroadcastMessage | Record<string, unknown>>
115
+ toDatabase: (_notifiable: Notifiable) => Record<string, unknown> | Promise<Record<string, unknown>>
116
+ toMail: (_notifiable: Notifiable) => MailMessage | Promise<MailMessage>
117
+ toSlack: (_notifiable: Notifiable) => SlackMessage | Promise<SlackMessage>
118
+ toSms: (_notifiable: Notifiable) => SmsMessage | Promise<SmsMessage>
119
+ }
120
+
121
+ class NotificationChannelNotConfiguredError = {
122
+ new (channel: string, hint?: string): NotificationChannelNotConfiguredError
123
+ readonly code: string
124
+ readonly context?: Record<string, unknown> | undefined
125
+ readonly status: number
126
+ }
127
+
128
+ class NotificationChannelUnavailableError = {
129
+ new (message: string): NotificationChannelUnavailableError
130
+ readonly code: string
131
+ readonly context?: Record<string, unknown> | undefined
132
+ readonly status: number
133
+ }
134
+
135
+ class NotificationConfigError = {
136
+ new (message: string, context?: Record<string, unknown>): NotificationConfigError
137
+ readonly code: string
138
+ readonly context?: Record<string, unknown> | undefined
139
+ readonly status: number
140
+ }
141
+
142
+ class NotificationContractError = {
143
+ new (notification: string, method: string, channel: string): NotificationContractError
144
+ readonly code: string
145
+ readonly context?: Record<string, unknown> | undefined
146
+ readonly status: number
147
+ }
148
+
149
+ class NotificationDeliveryError = {
150
+ new (message: string, context?: Record<string, unknown>): NotificationDeliveryError
151
+ readonly code: string
152
+ readonly context?: Record<string, unknown> | undefined
153
+ readonly status: number
154
+ }
155
+
156
+ class NotificationDispatchError = {
157
+ new (notification: string, failures: Array<{ channel: string; error: Error; }>, delivered: string[]): NotificationDispatchError
158
+ readonly code: string
159
+ readonly context?: Record<string, unknown> | undefined
160
+ readonly delivered: string[]
161
+ readonly failures: { channel: string; error: Error;}[]
162
+ readonly notification: string
163
+ readonly status: number
164
+ }
165
+
166
+ class NotificationError = {
167
+ new (message: string, code?: string, status?: number, context?: Record<string, unknown>): NotificationError
168
+ readonly code: string
169
+ readonly context?: Record<string, unknown> | undefined
170
+ readonly status: number
171
+ }
172
+
173
+ class NotificationFake = {
174
+ new (app: Application, original: Binding): NotificationFake
175
+ static install: () => NotificationFake
176
+ assertNotSentTo: <T extends Notification>(notifiable: Notifiable, NotificationClass: new (...args: never[]) => T) => void
177
+ assertNothingSent: () => void
178
+ assertQueued: <T extends Notification>(notifiable: Notifiable, NotificationClass: new (...args: never[]) => T) => void
179
+ assertSentCount: (count: number) => void
180
+ assertSentOn: <T extends Notification>(notifiable: Notifiable, NotificationClass: new (...args: never[]) => T, channel: string) => void
181
+ assertSentTimes: <T extends Notification>(NotificationClass: new (...args: never[]) => T, count: number) => void
182
+ assertSentTo: <T extends Notification>(notifiable: Notifiable, NotificationClass: new (...args: never[]) => T, callback?: (notification: T) => boolean) => void
183
+ channels: () => string[]
184
+ database: { all(): Promise<NotificationRecord[]>; unread(): Promise<NotificationRecord[]>; unreadCount(): Promise<number>; markAllAsRead(): Promise<void>; markAsRead(): Promise<void>; markAsUnread(): Promise<void>; delete(): Promise<void>; clear(): Promise<void>;}
185
+ extend: () => NotificationFake
186
+ queue: (notifiable: Notifiable, notification: Notification) => Promise<void>
187
+ queueMany: (notifiables: Iterable<Notifiable>, notification: Notification) => Promise<void>
188
+ restore: () => void
189
+ route: (routes: OnDemandRoutes) => { notify(notification: Notification): Promise<void>; notifyLater(notification: Notification): Promise<void>;}
190
+ send: (notifiable: Notifiable, notification: Notification) => Promise<void>
191
+ sendMany: (notifiables: Iterable<Notifiable>, notification: Notification) => Promise<void>
192
+ sent: () => CapturedNotification[]
193
+ sentTo: (notifiable: Notifiable) => CapturedNotification[]
194
+ }
195
+
196
+ class NotificationManager = {
197
+ new (config: NotificationConfigShape): NotificationManager
198
+ channels: () => string[]
199
+ database: DatabaseChannel
200
+ extend: (channel: string, factory: ChannelResolver) => NotificationManager
201
+ queue: (notifiable: Notifiable, notification: Notification) => Promise<void>
202
+ queueMany: (notifiables: Iterable<Notifiable>, notification: Notification) => Promise<void>
203
+ route: (routes: OnDemandRoutes) => { notify(notification: Notification): Promise<void>; notifyLater(notification: Notification): Promise<void>;}
204
+ send: (notifiable: Notifiable, notification: Notification) => Promise<void>
205
+ sendMany: (notifiables: Iterable<Notifiable>, notification: Notification) => Promise<void>
206
+ }
207
+
208
+ class NotificationProvider = {
209
+ new (app: Application): NotificationProvider
210
+ static dependsOn?: (new (app: Application) => ServiceProvider)[]
211
+ static environments: AppEnvironment[]
212
+ static priority?: number
213
+ static provides: readonly ['notifications']
214
+ devProcesses: () => DevProcessDefinition[]
215
+ doctorChecks: () => DoctorCheck[]
216
+ onBooted: () => Promise<void>
217
+ onBooting: () => Promise<void>
218
+ onRegister: () => void
219
+ onRequestProcessed: (_ctx: HttpContext) => Promise<void>
220
+ onRequestReceived: (_ctx: HttpContext) => Promise<void>
221
+ onResponseSent: (_ctx: HttpContext) => Promise<void>
222
+ onStarted: () => Promise<void>
223
+ onStarting: () => Promise<void>
224
+ onStopped: () => Promise<void>
225
+ onStopping: () => Promise<void>
226
+ replContext: () => Record<string, unknown>
227
+ }
228
+
229
+ class NotificationSent = {
230
+ new (className: string, channel: string, notifiable: string, ok: boolean, durationMs: number, error?: string | undefined): NotificationSent
231
+ readonly channel: string
232
+ readonly className: string
233
+ readonly durationMs: number
234
+ readonly error?: string | undefined
235
+ readonly notifiable: string
236
+ readonly ok: boolean
237
+ }
238
+
239
+ class NotificationsPruneCommand = {
240
+ new (): NotificationsPruneCommand
241
+ static args: ArgDef[]
242
+ static commandName: string
243
+ static description: string
244
+ static flags: FlagDef[]
245
+ static needsApp: boolean
246
+ _readLine: () => Promise<string>
247
+ _writer: OutputWriter
248
+ app: unknown
249
+ args: Record<string, string>
250
+ ask: (question: string, defaultValue?: string) => Promise<string>
251
+ choice: (question: string, options: string[]) => Promise<string>
252
+ confirm: (question: string, defaultValue?: boolean) => Promise<boolean>
253
+ dim: (msg: string) => void
254
+ error: (msg: string) => void
255
+ flags: Record<string, string | number | boolean>
256
+ info: (msg: string) => void
257
+ line: (msg: string) => void
258
+ newLine: () => void
259
+ run: () => Promise<void>
260
+ secret: (question: string) => Promise<string>
261
+ section: (title: string) => void
262
+ table: (rows: [string, string][], indent?: number) => void
263
+ warn: (msg: string) => void
264
+ write: (msg: string) => void
265
+ }
266
+
267
+ class NotificationsTestCommand = {
268
+ new (): NotificationsTestCommand
269
+ static args: ArgDef[]
270
+ static commandName: string
271
+ static description: string
272
+ static flags: FlagDef[]
273
+ static needsApp: boolean
274
+ _readLine: () => Promise<string>
275
+ _writer: OutputWriter
276
+ app: unknown
277
+ args: Record<string, string>
278
+ ask: (question: string, defaultValue?: string) => Promise<string>
279
+ choice: (question: string, options: string[]) => Promise<string>
280
+ confirm: (question: string, defaultValue?: boolean) => Promise<boolean>
281
+ dim: (msg: string) => void
282
+ error: (msg: string) => void
283
+ flags: Record<string, string | number | boolean>
284
+ info: (msg: string) => void
285
+ line: (msg: string) => void
286
+ newLine: () => void
287
+ run: () => Promise<void>
288
+ secret: (question: string) => Promise<string>
289
+ section: (title: string) => void
290
+ table: (rows: [string, string][], indent?: number) => void
291
+ warn: (msg: string) => void
292
+ write: (msg: string) => void
293
+ }
294
+
295
+ class OnDemandNotifiable = {
296
+ new (_routes: OnDemandRoutes): OnDemandNotifiable
297
+ readonly email?: string
298
+ readonly id: string
299
+ readonly phone?: string
300
+ receivesBroadcastNotificationsOn: () => string
301
+ routeNotificationFor: (channel: string) => string | undefined
302
+ }
303
+
304
+ class ResendDriver = {
305
+ new (_apiKey: string): ResendDriver
306
+ send: (message: MailPayload) => Promise<void>
307
+ }
308
+
309
+ class RichLine = {
310
+ new (): RichLine
311
+ bold: (content: string) => RichLine
312
+ color: (content: string, color: string) => RichLine
313
+ italic: (content: string) => RichLine
314
+ readonly segments: TextSegment[]
315
+ size: (content: string, px: number) => RichLine
316
+ styled: (content: string, style: TextStyle) => RichLine
317
+ text: (content: string) => RichLine
318
+ }
319
+
320
+ class SendNotificationJob = {
321
+ new (_notifiable?: Notifiable | undefined, _notification?: Notification | undefined, serialized?: SerializedNotification): SendNotificationJob
322
+ static fromPayload: (data: Record<string, unknown>) => SendNotificationJob
323
+ _chain: SerializedJob[] | undefined
324
+ batchId: string | undefined
325
+ className: string
326
+ debounceKey: () => string
327
+ handle: () => Promise<void>
328
+ payload: () => Record<string, unknown>
329
+ readonly debounce?: number
330
+ readonly maxAttempts: number
331
+ readonly queue: 'notifications'
332
+ readonly retryDelay: number
333
+ }
334
+
335
+ class SlackChannel = {
336
+ new (config?: { webhook?: string; }): SlackChannel
337
+ send: (notifiable: Notifiable, notification: Notification) => Promise<void>
338
+ }
339
+
340
+ class SmsChannel = {
341
+ new (config: SmsConfigShape): SmsChannel
342
+ send: (notifiable: Notifiable, notification: Notification) => Promise<void>
343
+ }
344
+
345
+ class SmtpConnectionError = {
346
+ new (message: string): SmtpConnectionError
347
+ readonly code: string
348
+ readonly context?: Record<string, unknown> | undefined
349
+ readonly status: number
350
+ }
351
+
352
+ class SmtpDriver = {
353
+ new (_host: string, _port: number, _username: string, _password: string, _secure?: boolean, _options?: { allowInsecureAuth?: boolean; rejectUnauthorized?: boolean; timeoutMs?: number; clientName?: string; }): SmtpDriver
354
+ send: (message: MailPayload) => Promise<void>
355
+ }
356
+
357
+ class SmtpResponseError = {
358
+ new (replyCode: number, replyText: string, expected: number[]): SmtpResponseError
359
+ readonly code: string
360
+ readonly context?: Record<string, unknown> | undefined
361
+ readonly replyCode: number
362
+ readonly replyText: string
363
+ readonly status: number
364
+ }
365
+
366
+ class UnknownNotificationChannelError = {
367
+ new (channel: string, known?: string[]): UnknownNotificationChannelError
368
+ readonly code: string
369
+ readonly context?: Record<string, unknown> | undefined
370
+ readonly status: number
371
+ }
372
+
373
+ class UnknownNotificationTypeError = {
374
+ new (type: string, known: string[]): UnknownNotificationTypeError
375
+ readonly code: string
376
+ readonly context?: Record<string, unknown> | undefined
377
+ readonly status: number
378
+ }
379
+
380
+ class UnknownSmsDriverError = {
381
+ new (driver: string): UnknownSmsDriverError
382
+ readonly code: string
383
+ readonly context?: Record<string, unknown> | undefined
384
+ readonly status: number
385
+ }
386
+
387
+ const BROADCAST_NOTIFICATION_EVENT = 'notification'
388
+
389
+ const NotificationRegistry = { _map: Map<string, NotificationClass>; register(NotificationClass: NotificationClass): void; resolve(className: string): NotificationClass | undefined; all(): ReadonlyMap<string, NotificationClass>;}
390
+
391
+ const Notify = NotificationManager
392
+
393
+ function channelStats = () => ChannelStat[]
394
+
395
+ function NotificationConfig = (options?: DeepPartial<NotificationConfigShape>) => NotificationConfigShape
396
+
397
+ function recentDeliveries = () => RecentDelivery[]
398
+
399
+ function validateNotificationConfig = (config: NotificationConfigShape) => void
400
+
401
+ interface ChannelStat = {
402
+ avgMs: number
403
+ channel: string
404
+ failed: number
405
+ sent: number
406
+ }
407
+
408
+ interface InboxQuery = {
409
+ limit?: number
410
+ offset?: number
411
+ }
412
+
413
+ interface MailAddress = {
414
+ address: string
415
+ name?: string
416
+ }
417
+
418
+ interface MailAttachment = {
419
+ cid?: string
420
+ content: string | Uint8Array<ArrayBufferLike>
421
+ contentType?: string
422
+ filename: string
423
+ inline?: boolean
424
+ }
425
+
426
+ interface MailConfigShape = {
427
+ driver: 'log' | 'smtp' | 'resend'
428
+ from: { address: string; name: string;}
429
+ log: { channel: 'console' | string;}
430
+ resend: { apiKey: string;}
431
+ smtp: { host: string; port: number; secure: boolean; username: string; password: string; allowInsecureAuth?: boolean; rejectUnauthorized?: boolean; timeoutMs?: number; clientName?: string;}
432
+ }
433
+
434
+ interface MailDriver = {
435
+ send: (message: MailPayload) => Promise<void>
436
+ }
437
+
438
+ interface MailPayload = {
439
+ attachments?: MailAttachment[]
440
+ bcc?: MailAddress[]
441
+ cc?: MailAddress[]
442
+ from: MailAddress
443
+ html?: string
444
+ replyTo?: MailAddress
445
+ subject: string
446
+ text?: string
447
+ to: MailAddress[]
448
+ }
449
+
450
+ interface Notifiable = {
451
+ email?: string
452
+ id: string | number
453
+ name?: string
454
+ phone?: string
455
+ receivesBroadcastNotificationsOn?: () => string
456
+ routeNotificationFor?: (channel: string) => string | undefined
457
+ }
458
+
459
+ interface NotificationChannel = {
460
+ send: (notifiable: Notifiable, notification: NotificationLike) => Promise<void>
461
+ }
462
+
463
+ interface NotificationConfigShape = {
464
+ database: { table: string;}
465
+ mail: MailConfigShape
466
+ slack?: { webhook?: string;}
467
+ sms?: SmsConfigShape
468
+ }
469
+
470
+ interface NotificationRecord = {
471
+ created_at: string
472
+ data: string
473
+ id: string
474
+ notifiable_id: string
475
+ notifiable_type: string
476
+ read_at: string | null
477
+ type: string
478
+ }
479
+
480
+ interface OnDemandRoutes = {
481
+ [key: string]: string | undefined
482
+ broadcast?: string
483
+ mail?: string
484
+ slack?: string
485
+ sms?: string
486
+ }
487
+
488
+ interface RecentDelivery = {
489
+ at: number
490
+ channel: string
491
+ className: string
492
+ durationMs: number
493
+ error: string | undefined
494
+ notifiable: string
495
+ ok: boolean
496
+ }
497
+
498
+ interface SlackMessage = {
499
+ blocks?: unknown[]
500
+ text: string
501
+ webhookUrl?: string
502
+ }
503
+
504
+ interface SmsConfigShape = {
505
+ driver: 'twilio' | 'vonage'
506
+ twilio?: TwilioConfigShape
507
+ vonage?: VonageConfigShape
508
+ }
509
+
510
+ interface SmsMessage = {
511
+ body: string
512
+ from?: string
513
+ to?: string
514
+ }
515
+
516
+ interface TextStyle = {
517
+ bold?: boolean
518
+ color?: string
519
+ italic?: boolean
520
+ size?: number
521
+ }
522
+
523
+ interface TwilioConfigShape = {
524
+ accountSid: string
525
+ authToken: string
526
+ from: string
527
+ }
528
+
529
+ interface VonageConfigShape = {
530
+ apiKey: string
531
+ apiSecret: string
532
+ from: string
533
+ }
534
+
535
+ type AddressInput = string | MailAddress
536
+
537
+ type NotificationClass = (new (...args: never[]) => Notification) & { fromPayload?(data: Record<string, unknown>): Notification | Promise<Notification>;}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@zerotal/notifications",
3
- "version": "1.6.3",
3
+ "version": "1.7.2",
4
4
  "license": "MIT",
5
5
  "maturity": "stable",
6
6
  "private": false,
@@ -12,6 +12,7 @@
12
12
  },
13
13
  "files": [
14
14
  "CHANGELOG.md",
15
+ "api-surface.md",
15
16
  "src",
16
17
  "!src/**/*.test.ts",
17
18
  "!src/**/*.test.tsx",
@@ -29,12 +30,12 @@
29
30
  "typecheck": "tsc --noEmit"
30
31
  },
31
32
  "dependencies": {
32
- "@zerotal/core": "1.6.3",
33
- "@zerotal/orm": "1.6.3",
34
- "@zerotal/queue": "1.6.3"
33
+ "@zerotal/core": "1.7.2",
34
+ "@zerotal/orm": "1.7.2",
35
+ "@zerotal/queue": "1.7.2"
35
36
  },
36
37
  "devDependencies": {
37
- "@zerotal/broadcasting": "1.6.3",
38
+ "@zerotal/broadcasting": "1.7.2",
38
39
  "typescript": "^5.8.0"
39
40
  },
40
41
  "description": "Multi-channel notifications for Zerotal — mail (SMTP/Resend), database, broadcast, Slack, and SMS.",
@@ -1,3 +1,4 @@
1
+ import { tryCurrentApp } from "@zerotal/core";
1
2
  import {
2
3
  resolveAddress,
3
4
  type AddressInput,
@@ -6,6 +7,35 @@ import {
6
7
  type MailPayload,
7
8
  } from "../drivers/MailDriver.ts";
8
9
 
10
+ /**
11
+ * Make an action URL absolute.
12
+ *
13
+ * A browser resolves `/projects/4` against the page it is on. An email client
14
+ * has no page, so a root-relative href in a delivered message is simply dead —
15
+ * and this is the one place the base URL cannot be left to the caller, because
16
+ * the caller is usually a queue job with no request to read an origin from.
17
+ *
18
+ * `config.app.url` is the origin the app already declares for exactly this.
19
+ * Resolution happens at render rather than in `action()` so a message can still
20
+ * be constructed outside an application — a unit test, or a driver being
21
+ * exercised on its own — where it is returned untouched.
22
+ *
23
+ * Anything already absolute is left alone, `mailto:` and `tel:` included.
24
+ */
25
+ function _absoluteUrl(url: string): string {
26
+ if (/^[a-z][a-z0-9+.-]*:/i.test(url) || url.startsWith("//")) return url;
27
+
28
+ const base = tryCurrentApp()?.container.tryMake("config")?.get<string>("app.url");
29
+ if (!base) return url;
30
+
31
+ try {
32
+ return new URL(url, base).toString();
33
+ } catch {
34
+ // A base that is not a URL is a config error, not this message's problem.
35
+ return url;
36
+ }
37
+ }
38
+
9
39
  /** Inline text styling applied to a whole line or to a single run within a line. */
10
40
  export interface TextStyle {
11
41
  bold?: boolean;
@@ -302,7 +332,7 @@ export class MailMessage {
302
332
  for (const l of this._introLines) parts.push(this._paragraph(l));
303
333
  if (this._actionText && this._actionUrl) {
304
334
  parts.push(
305
- `<p style="margin:24px 0"><a href="${escapeHtml(this._actionUrl)}" ` +
335
+ `<p style="margin:24px 0"><a href="${escapeHtml(_absoluteUrl(this._actionUrl))}" ` +
306
336
  `style="display:inline-block;background:#4f46e5;color:#fff;text-decoration:none;` +
307
337
  `padding:12px 20px;border-radius:8px;font-size:14px;font-weight:600">${escapeHtml(this._actionText)}</a></p>`,
308
338
  );
@@ -330,7 +360,8 @@ export class MailMessage {
330
360
  private _renderText(): string {
331
361
  const parts: string[] = [this._lineText(this._greeting ?? [{ text: "Hello," }])];
332
362
  for (const l of this._introLines) parts.push(this._lineText(l));
333
- if (this._actionText && this._actionUrl) parts.push(`${this._actionText}: ${this._actionUrl}`);
363
+ if (this._actionText && this._actionUrl)
364
+ parts.push(`${this._actionText}: ${_absoluteUrl(this._actionUrl)}`);
334
365
  for (const l of this._outroLines) parts.push(this._lineText(l));
335
366
  parts.push(this._lineText(this._salutation ?? [{ text: "Regards," }]));
336
367
  return parts.join("\n\n");