@strav/signal 0.1.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,111 @@
1
+ import { inject } from '@stravigor/kernel/core/inject'
2
+ import { ConfigurationError } from '@stravigor/kernel/exceptions/errors'
3
+ import Configuration from '@stravigor/kernel/config/configuration'
4
+ import { SmtpTransport } from './transports/smtp_transport.ts'
5
+ import { ResendTransport } from './transports/resend_transport.ts'
6
+ import { SendGridTransport } from './transports/sendgrid_transport.ts'
7
+ import { MailgunTransport } from './transports/mailgun_transport.ts'
8
+ import { AlibabaTransport } from './transports/alibaba_transport.ts'
9
+ import { LogTransport } from './transports/log_transport.ts'
10
+ import type { MailTransport, MailConfig } from './types.ts'
11
+
12
+ /**
13
+ * Central mail configuration hub.
14
+ *
15
+ * Resolved once via the DI container — reads the mail config
16
+ * and initializes the appropriate transport driver.
17
+ *
18
+ * @example
19
+ * app.singleton(MailManager)
20
+ * app.resolve(MailManager)
21
+ *
22
+ * // Plug in a custom transport
23
+ * MailManager.useTransport(new MyCustomTransport())
24
+ */
25
+ @inject
26
+ export default class MailManager {
27
+ private static _transport: MailTransport
28
+ private static _config: MailConfig
29
+
30
+ constructor(config: Configuration) {
31
+ const driverName = config.get('mail.default', 'log') as string
32
+
33
+ MailManager._config = {
34
+ default: driverName,
35
+ from: config.get('mail.from', 'noreply@localhost') as string,
36
+ templatePrefix: config.get('mail.templatePrefix', 'emails') as string,
37
+ inlineCss: config.get('mail.inlineCss', true) as boolean,
38
+ tailwind: config.get('mail.tailwind', false) as boolean,
39
+ smtp: {
40
+ host: '127.0.0.1',
41
+ port: 587,
42
+ secure: false,
43
+ ...(config.get('mail.smtp', {}) as object),
44
+ },
45
+ resend: {
46
+ apiKey: '',
47
+ ...(config.get('mail.resend', {}) as object),
48
+ },
49
+ sendgrid: {
50
+ apiKey: '',
51
+ ...(config.get('mail.sendgrid', {}) as object),
52
+ },
53
+ mailgun: {
54
+ apiKey: '',
55
+ domain: '',
56
+ ...(config.get('mail.mailgun', {}) as object),
57
+ },
58
+ alibaba: {
59
+ accessKeyId: '',
60
+ accessKeySecret: '',
61
+ accountName: '',
62
+ ...(config.get('mail.alibaba', {}) as object),
63
+ },
64
+ log: {
65
+ output: 'console',
66
+ ...(config.get('mail.log', {}) as object),
67
+ },
68
+ }
69
+
70
+ MailManager._transport = MailManager.createTransport(driverName)
71
+ }
72
+
73
+ private static createTransport(driver: string): MailTransport {
74
+ switch (driver) {
75
+ case 'smtp':
76
+ return new SmtpTransport(MailManager._config.smtp)
77
+ case 'resend':
78
+ return new ResendTransport(MailManager._config.resend)
79
+ case 'sendgrid':
80
+ return new SendGridTransport(MailManager._config.sendgrid)
81
+ case 'mailgun':
82
+ return new MailgunTransport(MailManager._config.mailgun)
83
+ case 'alibaba':
84
+ return new AlibabaTransport(MailManager._config.alibaba)
85
+ case 'log':
86
+ return new LogTransport(MailManager._config.log)
87
+ default:
88
+ throw new ConfigurationError(
89
+ `Unknown mail transport: ${driver}. Use MailManager.useTransport() for custom transports.`
90
+ )
91
+ }
92
+ }
93
+
94
+ static get transport(): MailTransport {
95
+ if (!MailManager._transport) {
96
+ throw new ConfigurationError(
97
+ 'MailManager not configured. Resolve it through the container first.'
98
+ )
99
+ }
100
+ return MailManager._transport
101
+ }
102
+
103
+ static get config(): MailConfig {
104
+ return MailManager._config
105
+ }
106
+
107
+ /** Swap the transport at runtime (e.g., for testing or a custom provider). */
108
+ static useTransport(transport: MailTransport): void {
109
+ MailManager._transport = transport
110
+ }
111
+ }
@@ -0,0 +1,88 @@
1
+ import crypto from 'node:crypto'
2
+ import { ExternalServiceError } from '@stravigor/kernel/exceptions/errors'
3
+ import type { MailTransport, MailMessage, MailResult, AlibabaConfig } from '../types.ts'
4
+
5
+ /**
6
+ * Alibaba Cloud DirectMail (SingleSendMail) transport.
7
+ * Uses fetch with HMAC-SHA1 signature — no SDK dependency required.
8
+ *
9
+ * Note: SingleSendMail does not support CC, BCC, or attachments.
10
+ * Use the SMTP interface for those features.
11
+ *
12
+ * @see https://www.alibabacloud.com/help/en/directmail/latest/SingleSendMail
13
+ */
14
+ export class AlibabaTransport implements MailTransport {
15
+ private accessKeyId: string
16
+ private accessKeySecret: string
17
+ private accountName: string
18
+ private region: string
19
+
20
+ constructor(config: AlibabaConfig) {
21
+ this.accessKeyId = config.accessKeyId
22
+ this.accessKeySecret = config.accessKeySecret
23
+ this.accountName = config.accountName
24
+ this.region = config.region ?? 'cn-hangzhou'
25
+ }
26
+
27
+ async send(message: MailMessage): Promise<MailResult> {
28
+ const toAddress = Array.isArray(message.to) ? message.to.join(',') : message.to
29
+
30
+ const params: Record<string, string> = {
31
+ Action: 'SingleSendMail',
32
+ AccountName: this.accountName,
33
+ AddressType: '1',
34
+ ReplyToAddress: message.replyTo ? 'true' : 'false',
35
+ ToAddress: toAddress,
36
+ Subject: message.subject,
37
+ Format: 'JSON',
38
+ Version: '2015-11-23',
39
+ AccessKeyId: this.accessKeyId,
40
+ SignatureMethod: 'HMAC-SHA1',
41
+ SignatureVersion: '1.0',
42
+ SignatureNonce: crypto.randomUUID(),
43
+ Timestamp: new Date().toISOString().replace(/\.\d{3}Z$/, 'Z'),
44
+ }
45
+
46
+ if (message.html) params.HtmlBody = message.html
47
+ if (message.text) params.TextBody = message.text
48
+ if (message.from) params.FromAlias = message.from
49
+
50
+ const signature = this.sign(params)
51
+ params.Signature = signature
52
+
53
+ const body = new URLSearchParams(params).toString()
54
+ const url = `https://dm.${this.region}.aliyuncs.com`
55
+
56
+ const response = await fetch(url, {
57
+ method: 'POST',
58
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
59
+ body,
60
+ })
61
+
62
+ if (!response.ok) {
63
+ const error = await response.text()
64
+ throw new ExternalServiceError('Alibaba DirectMail', response.status, error)
65
+ }
66
+
67
+ const data = (await response.json()) as { EnvId?: string; RequestId?: string }
68
+ const toArray = Array.isArray(message.to) ? message.to : [message.to]
69
+ return { messageId: data.EnvId ?? data.RequestId, accepted: toArray }
70
+ }
71
+
72
+ private percentEncode(str: string): string {
73
+ return encodeURIComponent(str).replace(/\+/g, '%20').replace(/\*/g, '%2A').replace(/~/g, '%7E')
74
+ }
75
+
76
+ private sign(params: Record<string, string>): string {
77
+ const sortedKeys = Object.keys(params).sort()
78
+ const canonicalized = sortedKeys
79
+ .map(k => `${this.percentEncode(k)}=${this.percentEncode(params[k]!)}`)
80
+ .join('&')
81
+
82
+ const stringToSign = `POST&${this.percentEncode('/')}&${this.percentEncode(canonicalized)}`
83
+
84
+ const hmac = crypto.createHmac('sha1', `${this.accessKeySecret}&`)
85
+ hmac.update(stringToSign)
86
+ return hmac.digest('base64')
87
+ }
88
+ }
@@ -0,0 +1,69 @@
1
+ import type { MailTransport, MailMessage, MailResult, LogConfig } from '../types.ts'
2
+
3
+ /**
4
+ * Development/testing transport — logs email details to console or a file.
5
+ * No external dependencies required.
6
+ */
7
+ export class LogTransport implements MailTransport {
8
+ private output: 'console' | string
9
+
10
+ constructor(config: LogConfig) {
11
+ this.output = config.output ?? 'console'
12
+ }
13
+
14
+ async send(message: MailMessage): Promise<MailResult> {
15
+ const timestamp = new Date().toISOString()
16
+ const separator = '─'.repeat(60)
17
+ const toList = Array.isArray(message.to) ? message.to.join(', ') : message.to
18
+
19
+ const lines = [
20
+ separator,
21
+ `[Mail] ${timestamp}`,
22
+ `From: ${message.from}`,
23
+ `To: ${toList}`,
24
+ ]
25
+
26
+ if (message.cc) {
27
+ const cc = Array.isArray(message.cc) ? message.cc.join(', ') : message.cc
28
+ lines.push(`CC: ${cc}`)
29
+ }
30
+ if (message.bcc) {
31
+ const bcc = Array.isArray(message.bcc) ? message.bcc.join(', ') : message.bcc
32
+ lines.push(`BCC: ${bcc}`)
33
+ }
34
+ if (message.replyTo) lines.push(`Reply-To: ${message.replyTo}`)
35
+
36
+ lines.push(`Subject: ${message.subject}`)
37
+
38
+ if (message.text) {
39
+ lines.push('', '--- Text ---', message.text)
40
+ }
41
+ if (message.html) {
42
+ lines.push('', '--- HTML ---', message.html)
43
+ }
44
+ if (message.attachments?.length) {
45
+ lines.push('', `--- Attachments (${message.attachments.length}) ---`)
46
+ for (const a of message.attachments) {
47
+ lines.push(` ${a.filename} (${a.contentType ?? 'unknown'})`)
48
+ }
49
+ }
50
+
51
+ lines.push(separator, '')
52
+
53
+ const output = lines.join('\n')
54
+
55
+ if (this.output === 'console') {
56
+ console.log(output)
57
+ } else {
58
+ const file = Bun.file(this.output)
59
+ const existing = (await file.exists()) ? await file.text() : ''
60
+ await Bun.write(this.output, existing + output + '\n')
61
+ }
62
+
63
+ const messageId = `log-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
64
+ return {
65
+ messageId,
66
+ accepted: Array.isArray(message.to) ? message.to : [message.to],
67
+ }
68
+ }
69
+ }
@@ -0,0 +1,74 @@
1
+ import { ExternalServiceError } from '@stravigor/kernel/exceptions/errors'
2
+ import type { MailTransport, MailMessage, MailResult, MailgunConfig } from '../types.ts'
3
+
4
+ /**
5
+ * Mailgun HTTP API transport.
6
+ * Uses fetch with FormData — no SDK dependency required.
7
+ *
8
+ * @see https://documentation.mailgun.com/docs/mailgun/api-reference/openapi-final/tag/Messages/
9
+ */
10
+ export class MailgunTransport implements MailTransport {
11
+ private apiKey: string
12
+ private domain: string
13
+ private baseUrl: string
14
+
15
+ constructor(config: MailgunConfig) {
16
+ this.apiKey = config.apiKey
17
+ this.domain = config.domain
18
+ this.baseUrl = config.baseUrl ?? 'https://api.mailgun.net'
19
+ }
20
+
21
+ async send(message: MailMessage): Promise<MailResult> {
22
+ const form = new FormData()
23
+
24
+ form.append('from', message.from)
25
+ form.append('subject', message.subject)
26
+
27
+ const toArray = Array.isArray(message.to) ? message.to : [message.to]
28
+ form.append('to', toArray.join(', '))
29
+
30
+ if (message.cc) {
31
+ const ccArray = Array.isArray(message.cc) ? message.cc : [message.cc]
32
+ form.append('cc', ccArray.join(', '))
33
+ }
34
+ if (message.bcc) {
35
+ const bccArray = Array.isArray(message.bcc) ? message.bcc : [message.bcc]
36
+ form.append('bcc', bccArray.join(', '))
37
+ }
38
+ if (message.replyTo) form.append('h:Reply-To', message.replyTo)
39
+ if (message.html) form.append('html', message.html)
40
+ if (message.text) form.append('text', message.text)
41
+
42
+ if (message.attachments?.length) {
43
+ for (const a of message.attachments) {
44
+ const content =
45
+ typeof a.content === 'string'
46
+ ? new Blob([a.content], { type: a.contentType ?? 'application/octet-stream' })
47
+ : new Blob([a.content], { type: a.contentType ?? 'application/octet-stream' })
48
+
49
+ if (a.cid) {
50
+ form.append('inline', content, a.filename)
51
+ } else {
52
+ form.append('attachment', content, a.filename)
53
+ }
54
+ }
55
+ }
56
+
57
+ const credentials = btoa(`api:${this.apiKey}`)
58
+ const response = await fetch(`${this.baseUrl}/v3/${this.domain}/messages`, {
59
+ method: 'POST',
60
+ headers: {
61
+ Authorization: `Basic ${credentials}`,
62
+ },
63
+ body: form,
64
+ })
65
+
66
+ if (!response.ok) {
67
+ const error = await response.text()
68
+ throw new ExternalServiceError('Mailgun', response.status, error)
69
+ }
70
+
71
+ const data = (await response.json()) as { id: string }
72
+ return { messageId: data.id, accepted: toArray }
73
+ }
74
+ }
@@ -0,0 +1,58 @@
1
+ import { ExternalServiceError } from '@stravigor/kernel/exceptions/errors'
2
+ import type { MailTransport, MailMessage, MailResult, ResendConfig } from '../types.ts'
3
+
4
+ /**
5
+ * Resend HTTP API transport.
6
+ * Uses fetch — no SDK dependency required.
7
+ *
8
+ * @see https://resend.com/docs/api-reference/emails/send-email
9
+ */
10
+ export class ResendTransport implements MailTransport {
11
+ private apiKey: string
12
+ private baseUrl: string
13
+
14
+ constructor(config: ResendConfig) {
15
+ this.apiKey = config.apiKey
16
+ this.baseUrl = config.baseUrl ?? 'https://api.resend.com'
17
+ }
18
+
19
+ async send(message: MailMessage): Promise<MailResult> {
20
+ const body: Record<string, unknown> = {
21
+ from: message.from,
22
+ to: Array.isArray(message.to) ? message.to : [message.to],
23
+ subject: message.subject,
24
+ }
25
+
26
+ if (message.cc) body.cc = Array.isArray(message.cc) ? message.cc : [message.cc]
27
+ if (message.bcc) body.bcc = Array.isArray(message.bcc) ? message.bcc : [message.bcc]
28
+ if (message.replyTo) body.reply_to = message.replyTo
29
+ if (message.html) body.html = message.html
30
+ if (message.text) body.text = message.text
31
+
32
+ if (message.attachments?.length) {
33
+ body.attachments = message.attachments.map(a => ({
34
+ filename: a.filename,
35
+ content:
36
+ typeof a.content === 'string' ? a.content : Buffer.from(a.content).toString('base64'),
37
+ content_type: a.contentType,
38
+ }))
39
+ }
40
+
41
+ const response = await fetch(`${this.baseUrl}/emails`, {
42
+ method: 'POST',
43
+ headers: {
44
+ Authorization: `Bearer ${this.apiKey}`,
45
+ 'Content-Type': 'application/json',
46
+ },
47
+ body: JSON.stringify(body),
48
+ })
49
+
50
+ if (!response.ok) {
51
+ const error = await response.text()
52
+ throw new ExternalServiceError('Resend', response.status, error)
53
+ }
54
+
55
+ const data = (await response.json()) as { id: string }
56
+ return { messageId: data.id }
57
+ }
58
+ }
@@ -0,0 +1,80 @@
1
+ import { ExternalServiceError } from '@stravigor/kernel/exceptions/errors'
2
+ import type { MailTransport, MailMessage, MailResult, SendGridConfig } from '../types.ts'
3
+
4
+ /**
5
+ * SendGrid v3 Mail Send API transport.
6
+ * Uses fetch — no SDK dependency required.
7
+ *
8
+ * @see https://docs.sendgrid.com/api-reference/mail-send/mail-send
9
+ */
10
+ export class SendGridTransport implements MailTransport {
11
+ private apiKey: string
12
+ private baseUrl: string
13
+
14
+ constructor(config: SendGridConfig) {
15
+ this.apiKey = config.apiKey
16
+ this.baseUrl = config.baseUrl ?? 'https://api.sendgrid.com/v3'
17
+ }
18
+
19
+ async send(message: MailMessage): Promise<MailResult> {
20
+ const toArray = Array.isArray(message.to) ? message.to : [message.to]
21
+
22
+ const personalizations: Record<string, unknown>[] = [
23
+ {
24
+ to: toArray.map(email => ({ email })),
25
+ },
26
+ ]
27
+
28
+ if (message.cc) {
29
+ const ccArray = Array.isArray(message.cc) ? message.cc : [message.cc]
30
+ personalizations[0]!.cc = ccArray.map(email => ({ email }))
31
+ }
32
+ if (message.bcc) {
33
+ const bccArray = Array.isArray(message.bcc) ? message.bcc : [message.bcc]
34
+ personalizations[0]!.bcc = bccArray.map(email => ({ email }))
35
+ }
36
+
37
+ const content: { type: string; value: string }[] = []
38
+ if (message.text) content.push({ type: 'text/plain', value: message.text })
39
+ if (message.html) content.push({ type: 'text/html', value: message.html })
40
+
41
+ const body: Record<string, unknown> = {
42
+ personalizations,
43
+ from: { email: message.from },
44
+ subject: message.subject,
45
+ content,
46
+ }
47
+
48
+ if (message.replyTo) body.reply_to = { email: message.replyTo }
49
+
50
+ if (message.attachments?.length) {
51
+ body.attachments = message.attachments.map(a => ({
52
+ filename: a.filename,
53
+ content:
54
+ typeof a.content === 'string'
55
+ ? Buffer.from(a.content).toString('base64')
56
+ : Buffer.from(a.content).toString('base64'),
57
+ type: a.contentType,
58
+ disposition: a.cid ? 'inline' : 'attachment',
59
+ content_id: a.cid,
60
+ }))
61
+ }
62
+
63
+ const response = await fetch(`${this.baseUrl}/mail/send`, {
64
+ method: 'POST',
65
+ headers: {
66
+ Authorization: `Bearer ${this.apiKey}`,
67
+ 'Content-Type': 'application/json',
68
+ },
69
+ body: JSON.stringify(body),
70
+ })
71
+
72
+ if (!response.ok) {
73
+ const error = await response.text()
74
+ throw new ExternalServiceError('SendGrid', response.status, error)
75
+ }
76
+
77
+ const messageId = response.headers.get('X-Message-Id') ?? undefined
78
+ return { messageId, accepted: toArray }
79
+ }
80
+ }
@@ -0,0 +1,48 @@
1
+ import nodemailer from 'nodemailer'
2
+ import type { MailTransport, MailMessage, MailResult, SmtpConfig } from '../types.ts'
3
+
4
+ /**
5
+ * SMTP transport via nodemailer.
6
+ * Supports all standard SMTP features including TLS, auth, and attachments.
7
+ */
8
+ export class SmtpTransport implements MailTransport {
9
+ private transporter: nodemailer.Transporter
10
+
11
+ constructor(config: SmtpConfig) {
12
+ this.transporter = nodemailer.createTransport({
13
+ host: config.host,
14
+ port: config.port,
15
+ secure: config.secure,
16
+ auth: config.auth?.user ? { user: config.auth.user, pass: config.auth.pass } : undefined,
17
+ })
18
+ }
19
+
20
+ async send(message: MailMessage): Promise<MailResult> {
21
+ const info = await this.transporter.sendMail({
22
+ from: message.from,
23
+ to: Array.isArray(message.to) ? message.to.join(', ') : message.to,
24
+ cc: message.cc ? (Array.isArray(message.cc) ? message.cc.join(', ') : message.cc) : undefined,
25
+ bcc: message.bcc
26
+ ? Array.isArray(message.bcc)
27
+ ? message.bcc.join(', ')
28
+ : message.bcc
29
+ : undefined,
30
+ replyTo: message.replyTo,
31
+ subject: message.subject,
32
+ html: message.html,
33
+ text: message.text,
34
+ attachments: message.attachments?.map(a => ({
35
+ filename: a.filename,
36
+ content: a.content,
37
+ contentType: a.contentType,
38
+ cid: a.cid,
39
+ })),
40
+ })
41
+
42
+ return {
43
+ messageId: info.messageId,
44
+ accepted: Array.isArray(info.accepted) ? info.accepted.map(String) : [],
45
+ rejected: Array.isArray(info.rejected) ? info.rejected.map(String) : [],
46
+ }
47
+ }
48
+ }
@@ -0,0 +1,98 @@
1
+ /** Core message structure passed to transports. */
2
+ export interface MailMessage {
3
+ from: string
4
+ to: string | string[]
5
+ cc?: string | string[]
6
+ bcc?: string | string[]
7
+ replyTo?: string
8
+ subject: string
9
+ html?: string
10
+ text?: string
11
+ attachments?: MailAttachment[]
12
+ }
13
+
14
+ export interface MailAttachment {
15
+ filename: string
16
+ content: Buffer | string
17
+ contentType?: string
18
+ /** For CID-referenced inline images. */
19
+ cid?: string
20
+ }
21
+
22
+ export interface MailResult {
23
+ messageId?: string
24
+ accepted?: string[]
25
+ rejected?: string[]
26
+ }
27
+
28
+ /**
29
+ * Pluggable transport backend.
30
+ * Implement this interface for custom mail providers.
31
+ */
32
+ export interface MailTransport {
33
+ send(message: MailMessage): Promise<MailResult>
34
+ }
35
+
36
+ // -- Per-driver configs -------------------------------------------------------
37
+
38
+ export interface SmtpConfig {
39
+ host: string
40
+ port: number
41
+ secure: boolean
42
+ auth?: {
43
+ user: string
44
+ pass: string
45
+ }
46
+ }
47
+
48
+ export interface ResendConfig {
49
+ apiKey: string
50
+ baseUrl?: string
51
+ }
52
+
53
+ export interface SendGridConfig {
54
+ apiKey: string
55
+ baseUrl?: string
56
+ }
57
+
58
+ export interface MailgunConfig {
59
+ apiKey: string
60
+ domain: string
61
+ /** Default: 'https://api.mailgun.net'. EU: 'https://api.eu.mailgun.net' */
62
+ baseUrl?: string
63
+ }
64
+
65
+ export interface AlibabaConfig {
66
+ accessKeyId: string
67
+ accessKeySecret: string
68
+ /** Sender address configured in Alibaba DirectMail. */
69
+ accountName: string
70
+ /** Default: 'cn-hangzhou' */
71
+ region?: string
72
+ }
73
+
74
+ export interface LogConfig {
75
+ /** Write to 'console' or a file path. */
76
+ output: 'console' | string
77
+ }
78
+
79
+ // -- Top-level mail config ----------------------------------------------------
80
+
81
+ export interface MailConfig {
82
+ /** Default transport name: 'smtp' | 'resend' | 'sendgrid' | 'mailgun' | 'alibaba' | 'log' */
83
+ default: string
84
+ /** Default "from" address. */
85
+ from: string
86
+ /** Template prefix for ViewEngine (default: 'emails'). */
87
+ templatePrefix: string
88
+ /** Enable CSS inlining via juice (default: true). */
89
+ inlineCss: boolean
90
+ /** Enable Tailwind CSS compilation before inlining (default: false). */
91
+ tailwind: boolean
92
+ smtp: SmtpConfig
93
+ resend: ResendConfig
94
+ sendgrid: SendGridConfig
95
+ mailgun: MailgunConfig
96
+ alibaba: AlibabaConfig
97
+ log: LogConfig
98
+ }
@@ -0,0 +1,67 @@
1
+ import type {
2
+ Notifiable,
3
+ MailEnvelope,
4
+ DatabaseEnvelope,
5
+ WebhookEnvelope,
6
+ DiscordEnvelope,
7
+ NotificationPayload,
8
+ } from './types.ts'
9
+
10
+ /**
11
+ * Base class for all notifications.
12
+ *
13
+ * Extend this class and implement `via()` plus at least one `toXxx()` method
14
+ * to define how the notification should be delivered on each channel.
15
+ *
16
+ * @example
17
+ * class TaskAssignedNotification extends BaseNotification {
18
+ * constructor(private task: Task, private assigner: User) { super() }
19
+ *
20
+ * via() { return ['email', 'database'] }
21
+ *
22
+ * toEmail(notifiable: Notifiable): MailEnvelope {
23
+ * return { subject: `Assigned: ${this.task.title}`, template: 'task-assigned', templateData: { ... } }
24
+ * }
25
+ *
26
+ * toDatabase(): DatabaseEnvelope {
27
+ * return { type: 'task.assigned', data: { taskId: this.task.id } }
28
+ * }
29
+ *
30
+ * shouldQueue() { return true }
31
+ * }
32
+ */
33
+ export abstract class BaseNotification {
34
+ /** Which channels this notification should be sent on. */
35
+ abstract via(notifiable: Notifiable): string[]
36
+
37
+ /** Build the email envelope. */
38
+ toEmail?(notifiable: Notifiable): MailEnvelope
39
+ /** Build the database (in-app) envelope. */
40
+ toDatabase?(notifiable: Notifiable): DatabaseEnvelope
41
+ /** Build the webhook envelope. */
42
+ toWebhook?(notifiable: Notifiable): WebhookEnvelope
43
+ /** Build the Discord envelope. */
44
+ toDiscord?(notifiable: Notifiable): DiscordEnvelope
45
+
46
+ /** Whether this notification should be queued for async delivery. */
47
+ shouldQueue(): boolean {
48
+ return false
49
+ }
50
+
51
+ /** Queue options (queue name, delay in ms, max attempts). */
52
+ queueOptions(): { queue?: string; delay?: number; attempts?: number } {
53
+ return {}
54
+ }
55
+
56
+ /** Build a serializable payload containing all channel envelopes. */
57
+ buildPayload(notifiable: Notifiable): NotificationPayload {
58
+ return {
59
+ notificationClass: this.constructor.name,
60
+ channels: this.via(notifiable),
61
+ mail: this.toEmail?.(notifiable),
62
+ database: this.toDatabase?.(notifiable),
63
+ webhook: this.toWebhook?.(notifiable),
64
+ discord: this.toDiscord?.(notifiable),
65
+ }
66
+ }
67
+ }