@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.
- package/package.json +48 -0
- package/src/broadcast/broadcast_manager.ts +424 -0
- package/src/broadcast/client.ts +308 -0
- package/src/broadcast/index.ts +58 -0
- package/src/index.ts +4 -0
- package/src/mail/css_inliner.ts +79 -0
- package/src/mail/helpers.ts +212 -0
- package/src/mail/index.ts +23 -0
- package/src/mail/mail_manager.ts +111 -0
- package/src/mail/transports/alibaba_transport.ts +88 -0
- package/src/mail/transports/log_transport.ts +69 -0
- package/src/mail/transports/mailgun_transport.ts +74 -0
- package/src/mail/transports/resend_transport.ts +58 -0
- package/src/mail/transports/sendgrid_transport.ts +80 -0
- package/src/mail/transports/smtp_transport.ts +48 -0
- package/src/mail/types.ts +98 -0
- package/src/notification/base_notification.ts +67 -0
- package/src/notification/channels/database_channel.ts +30 -0
- package/src/notification/channels/discord_channel.ts +48 -0
- package/src/notification/channels/email_channel.ts +37 -0
- package/src/notification/channels/webhook_channel.ts +50 -0
- package/src/notification/helpers.ts +214 -0
- package/src/notification/index.ts +20 -0
- package/src/notification/notification_manager.ts +127 -0
- package/src/notification/types.ts +122 -0
- package/src/providers/broadcast_provider.ts +22 -0
- package/src/providers/index.ts +5 -0
- package/src/providers/mail_provider.ts +16 -0
- package/src/providers/notification_provider.ts +29 -0
- package/tsconfig.json +5 -0
|
@@ -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
|
+
}
|