@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,308 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-side Broadcast client.
|
|
3
|
+
*
|
|
4
|
+
* Connects to the server's broadcast WebSocket endpoint,
|
|
5
|
+
* manages channel subscriptions, auto-reconnects, and routes
|
|
6
|
+
* events to subscription listeners.
|
|
7
|
+
*
|
|
8
|
+
* Zero dependencies — works in any browser.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* import { Broadcast } from '@stravigor/signal/broadcast/client'
|
|
12
|
+
*
|
|
13
|
+
* const bc = new Broadcast()
|
|
14
|
+
*
|
|
15
|
+
* const chat = bc.subscribe('chat/1')
|
|
16
|
+
* chat.on('new_message', (data) => console.log(data))
|
|
17
|
+
* chat.send('send', { text: 'Hello!' })
|
|
18
|
+
*
|
|
19
|
+
* bc.on('connected', () => console.log('Online'))
|
|
20
|
+
* bc.on('disconnected', () => console.log('Offline'))
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
// ---------------------------------------------------------------------------
|
|
24
|
+
// Types
|
|
25
|
+
// ---------------------------------------------------------------------------
|
|
26
|
+
|
|
27
|
+
export interface BroadcastOptions {
|
|
28
|
+
/** WebSocket URL. Default: auto-detected from current host + `/_broadcast`. */
|
|
29
|
+
url?: string
|
|
30
|
+
/** Maximum reconnection attempts. Default: Infinity */
|
|
31
|
+
maxReconnectAttempts?: number
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
type Callback = (...args: any[]) => void
|
|
35
|
+
|
|
36
|
+
// ---------------------------------------------------------------------------
|
|
37
|
+
// Subscription
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* A subscription to a single broadcast channel.
|
|
42
|
+
*
|
|
43
|
+
* Listen for server events, send messages to the channel,
|
|
44
|
+
* or leave the channel entirely.
|
|
45
|
+
*
|
|
46
|
+
* @example
|
|
47
|
+
* const sub = bc.subscribe('chat/1')
|
|
48
|
+
* sub.on('message', (data) => { ... })
|
|
49
|
+
* sub.send('typing', { active: true })
|
|
50
|
+
* sub.leave()
|
|
51
|
+
*/
|
|
52
|
+
export class Subscription {
|
|
53
|
+
private listeners = new Map<string, Set<Callback>>()
|
|
54
|
+
|
|
55
|
+
constructor(
|
|
56
|
+
/** The channel name this subscription is bound to. */
|
|
57
|
+
readonly channel: string,
|
|
58
|
+
private sendFn: (msg: object) => void,
|
|
59
|
+
private leaveFn: () => void
|
|
60
|
+
) {}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Listen for a specific event on this channel.
|
|
64
|
+
* Returns a function that removes the listener when called.
|
|
65
|
+
*
|
|
66
|
+
* @example
|
|
67
|
+
* const stop = sub.on('message', (data) => console.log(data))
|
|
68
|
+
* stop() // remove listener
|
|
69
|
+
*/
|
|
70
|
+
on(event: string, callback: Callback): () => void {
|
|
71
|
+
let set = this.listeners.get(event)
|
|
72
|
+
if (!set) {
|
|
73
|
+
set = new Set()
|
|
74
|
+
this.listeners.set(event, set)
|
|
75
|
+
}
|
|
76
|
+
set.add(callback)
|
|
77
|
+
return () => {
|
|
78
|
+
set!.delete(callback)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Send a message to the server on this channel.
|
|
84
|
+
*
|
|
85
|
+
* @example
|
|
86
|
+
* sub.send('typing', { active: true })
|
|
87
|
+
*/
|
|
88
|
+
send(event: string, data?: unknown): void {
|
|
89
|
+
this.sendFn({ t: 'msg', c: this.channel, e: event, d: data })
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Unsubscribe from this channel. */
|
|
93
|
+
leave(): void {
|
|
94
|
+
this.sendFn({ t: 'unsub', c: this.channel })
|
|
95
|
+
this.listeners.clear()
|
|
96
|
+
this.leaveFn()
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** @internal Dispatch an incoming event to registered listeners. */
|
|
100
|
+
_dispatch(event: string, data: unknown): void {
|
|
101
|
+
const set = this.listeners.get(event)
|
|
102
|
+
if (set) for (const cb of set) cb(data)
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ---------------------------------------------------------------------------
|
|
107
|
+
// Broadcast
|
|
108
|
+
// ---------------------------------------------------------------------------
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Broadcast client — manages a single WebSocket connection
|
|
112
|
+
* with multiplexed channel subscriptions.
|
|
113
|
+
*
|
|
114
|
+
* @example
|
|
115
|
+
* const bc = new Broadcast()
|
|
116
|
+
* const chat = bc.subscribe('chat/1')
|
|
117
|
+
* chat.on('message', (data) => console.log(data))
|
|
118
|
+
*/
|
|
119
|
+
export class Broadcast {
|
|
120
|
+
private ws: WebSocket | null = null
|
|
121
|
+
private url: string
|
|
122
|
+
private maxReconnectAttempts: number
|
|
123
|
+
private reconnectAttempt = 0
|
|
124
|
+
private reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
|
125
|
+
private subscriptions = new Map<string, Subscription>()
|
|
126
|
+
private listeners = new Map<string, Set<Callback>>()
|
|
127
|
+
private queue: string[] = []
|
|
128
|
+
private _connected = false
|
|
129
|
+
private _clientId: string | null = null
|
|
130
|
+
|
|
131
|
+
constructor(options?: BroadcastOptions) {
|
|
132
|
+
this.url = options?.url ?? this.autoUrl()
|
|
133
|
+
this.maxReconnectAttempts = options?.maxReconnectAttempts ?? Infinity
|
|
134
|
+
this.connect()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Whether the WebSocket connection is currently open. */
|
|
138
|
+
get connected(): boolean {
|
|
139
|
+
return this._connected
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The unique client ID assigned by the server, or null if not yet connected. */
|
|
143
|
+
get clientId(): string | null {
|
|
144
|
+
return this._clientId
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Subscribe to a broadcast channel.
|
|
149
|
+
*
|
|
150
|
+
* Returns an existing subscription if already subscribed.
|
|
151
|
+
* On reconnect, all active subscriptions are automatically re-established.
|
|
152
|
+
*
|
|
153
|
+
* @example
|
|
154
|
+
* const notifications = bc.subscribe('notifications')
|
|
155
|
+
* notifications.on('alert', (data) => showToast(data.text))
|
|
156
|
+
*/
|
|
157
|
+
subscribe(channel: string): Subscription {
|
|
158
|
+
const existing = this.subscriptions.get(channel)
|
|
159
|
+
if (existing) return existing
|
|
160
|
+
|
|
161
|
+
const sub = new Subscription(
|
|
162
|
+
channel,
|
|
163
|
+
msg => this.send(msg),
|
|
164
|
+
() => this.subscriptions.delete(channel)
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
this.subscriptions.set(channel, sub)
|
|
168
|
+
|
|
169
|
+
if (this._connected) {
|
|
170
|
+
this.rawSend({ t: 'sub', c: channel })
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return sub
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Listen for connection lifecycle events.
|
|
178
|
+
*
|
|
179
|
+
* Events:
|
|
180
|
+
* - `connected` — WebSocket connection established
|
|
181
|
+
* - `disconnected` — WebSocket connection lost
|
|
182
|
+
* - `reconnecting` — reconnection attempt (callback receives attempt number)
|
|
183
|
+
* - `subscribed` — channel subscription confirmed (callback receives channel name)
|
|
184
|
+
* - `error` — subscription error (callback receives `{ channel, reason }`)
|
|
185
|
+
*
|
|
186
|
+
* Returns a function that removes the listener.
|
|
187
|
+
*/
|
|
188
|
+
on(event: string, callback: Callback): () => void {
|
|
189
|
+
let set = this.listeners.get(event)
|
|
190
|
+
if (!set) {
|
|
191
|
+
set = new Set()
|
|
192
|
+
this.listeners.set(event, set)
|
|
193
|
+
}
|
|
194
|
+
set.add(callback)
|
|
195
|
+
return () => {
|
|
196
|
+
set!.delete(callback)
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Close the connection permanently (no reconnection). */
|
|
201
|
+
close(): void {
|
|
202
|
+
this.reconnectAttempt = Infinity
|
|
203
|
+
if (this.reconnectTimer) clearTimeout(this.reconnectTimer)
|
|
204
|
+
this.ws?.close()
|
|
205
|
+
this.ws = null
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
// ---------------------------------------------------------------------------
|
|
209
|
+
// Internal
|
|
210
|
+
// ---------------------------------------------------------------------------
|
|
211
|
+
|
|
212
|
+
private autoUrl(): string {
|
|
213
|
+
// @ts-ignore browser-only API
|
|
214
|
+
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:'
|
|
215
|
+
// @ts-ignore browser-only API
|
|
216
|
+
return `${proto}//${location.host}/_broadcast`
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
private connect(): void {
|
|
220
|
+
this.ws = new WebSocket(this.url)
|
|
221
|
+
|
|
222
|
+
this.ws.onopen = () => {
|
|
223
|
+
this._connected = true
|
|
224
|
+
this.reconnectAttempt = 0
|
|
225
|
+
this.emit('connected')
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
this.ws.onmessage = event => {
|
|
229
|
+
try {
|
|
230
|
+
const msg = JSON.parse(event.data)
|
|
231
|
+
this.handleMessage(msg)
|
|
232
|
+
} catch {}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
this.ws.onclose = () => {
|
|
236
|
+
const wasConnected = this._connected
|
|
237
|
+
this._connected = false
|
|
238
|
+
if (wasConnected) this.emit('disconnected')
|
|
239
|
+
this.scheduleReconnect()
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
this.ws.onerror = () => {
|
|
243
|
+
this.ws?.close()
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
private handleMessage(msg: any): void {
|
|
248
|
+
switch (msg.t) {
|
|
249
|
+
case 'welcome':
|
|
250
|
+
this._clientId = msg.id
|
|
251
|
+
// Re-subscribe to all active channels
|
|
252
|
+
for (const channel of this.subscriptions.keys()) {
|
|
253
|
+
this.rawSend({ t: 'sub', c: channel })
|
|
254
|
+
}
|
|
255
|
+
// Flush queued messages
|
|
256
|
+
for (const raw of this.queue) {
|
|
257
|
+
this.ws!.send(raw)
|
|
258
|
+
}
|
|
259
|
+
this.queue = []
|
|
260
|
+
break
|
|
261
|
+
|
|
262
|
+
case 'ok':
|
|
263
|
+
this.emit('subscribed', msg.c)
|
|
264
|
+
break
|
|
265
|
+
|
|
266
|
+
case 'err':
|
|
267
|
+
this.emit('error', { channel: msg.c, reason: msg.r })
|
|
268
|
+
break
|
|
269
|
+
|
|
270
|
+
case 'msg':
|
|
271
|
+
this.subscriptions.get(msg.c)?._dispatch(msg.e, msg.d)
|
|
272
|
+
break
|
|
273
|
+
|
|
274
|
+
case 'ping':
|
|
275
|
+
this.rawSend({ t: 'pong' })
|
|
276
|
+
break
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
private send(msg: object): void {
|
|
281
|
+
const raw = JSON.stringify(msg)
|
|
282
|
+
if (this._connected && this.ws?.readyState === WebSocket.OPEN) {
|
|
283
|
+
this.ws.send(raw)
|
|
284
|
+
} else {
|
|
285
|
+
this.queue.push(raw)
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
private rawSend(msg: object): void {
|
|
290
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
291
|
+
this.ws.send(JSON.stringify(msg))
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
private scheduleReconnect(): void {
|
|
296
|
+
if (this.reconnectAttempt >= this.maxReconnectAttempts) return
|
|
297
|
+
|
|
298
|
+
this.reconnectAttempt++
|
|
299
|
+
const delay = Math.min(1000 * Math.pow(2, this.reconnectAttempt - 1), 30_000)
|
|
300
|
+
this.emit('reconnecting', this.reconnectAttempt)
|
|
301
|
+
this.reconnectTimer = setTimeout(() => this.connect(), delay)
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
private emit(event: string, data?: unknown): void {
|
|
305
|
+
const set = this.listeners.get(event)
|
|
306
|
+
if (set) for (const cb of set) cb(data)
|
|
307
|
+
}
|
|
308
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
export { default, default as BroadcastManager } from './broadcast_manager.ts'
|
|
2
|
+
export type {
|
|
3
|
+
AuthorizeCallback,
|
|
4
|
+
MessageHandler,
|
|
5
|
+
ChannelConfig,
|
|
6
|
+
BootOptions,
|
|
7
|
+
PendingBroadcast,
|
|
8
|
+
} from './broadcast_manager.ts'
|
|
9
|
+
export { Broadcast, Subscription } from './client.ts'
|
|
10
|
+
export type { BroadcastOptions } from './client.ts'
|
|
11
|
+
|
|
12
|
+
import BroadcastManager from './broadcast_manager.ts'
|
|
13
|
+
import type { AuthorizeCallback, ChannelConfig, BootOptions } from './broadcast_manager.ts'
|
|
14
|
+
import type Router from '@stravigor/http/http/router'
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Broadcast helper — convenience object that delegates to `BroadcastManager`.
|
|
18
|
+
*
|
|
19
|
+
* @example
|
|
20
|
+
* import { broadcast } from '@stravigor/signal/broadcast'
|
|
21
|
+
*
|
|
22
|
+
* // Bootstrap
|
|
23
|
+
* broadcast.boot(router, { middleware: [session()] })
|
|
24
|
+
*
|
|
25
|
+
* // Define channels
|
|
26
|
+
* broadcast.channel('notifications')
|
|
27
|
+
* broadcast.channel('chats/:id', async (ctx, { id }) => !!ctx.get('user'))
|
|
28
|
+
*
|
|
29
|
+
* // Broadcast from anywhere
|
|
30
|
+
* broadcast.to('notifications').send('alert', { text: 'Hello' })
|
|
31
|
+
* broadcast.to(`chats/${chatId}`).except(senderId).send('message', data)
|
|
32
|
+
*/
|
|
33
|
+
export const broadcast = {
|
|
34
|
+
/** Register the broadcast WebSocket endpoint on the router. */
|
|
35
|
+
boot(router: Router, options?: BootOptions): void {
|
|
36
|
+
BroadcastManager.boot(router, options)
|
|
37
|
+
},
|
|
38
|
+
|
|
39
|
+
/** Register a channel with optional authorization and message handlers. */
|
|
40
|
+
channel(pattern: string, config?: AuthorizeCallback | ChannelConfig): void {
|
|
41
|
+
BroadcastManager.channel(pattern, config)
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
/** Begin a broadcast to a channel. */
|
|
45
|
+
to(channel: string) {
|
|
46
|
+
return BroadcastManager.to(channel)
|
|
47
|
+
},
|
|
48
|
+
|
|
49
|
+
/** Number of active WebSocket connections. */
|
|
50
|
+
get clientCount() {
|
|
51
|
+
return BroadcastManager.clientCount
|
|
52
|
+
},
|
|
53
|
+
|
|
54
|
+
/** Number of subscribers on a specific channel. */
|
|
55
|
+
subscriberCount(channel: string) {
|
|
56
|
+
return BroadcastManager.subscriberCount(channel)
|
|
57
|
+
},
|
|
58
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import juice from 'juice'
|
|
2
|
+
|
|
3
|
+
export interface InlinerOptions {
|
|
4
|
+
/** Enable CSS inlining (default: true). */
|
|
5
|
+
enabled: boolean
|
|
6
|
+
/** Enable Tailwind CSS compilation before inlining (default: false). */
|
|
7
|
+
tailwind: boolean
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Process rendered HTML for email delivery:
|
|
12
|
+
* 1. Optionally compile Tailwind classes to CSS
|
|
13
|
+
* 2. Inline all <style> blocks into style="" attributes via juice
|
|
14
|
+
*/
|
|
15
|
+
export async function inlineCss(html: string, options: InlinerOptions): Promise<string> {
|
|
16
|
+
if (!options.enabled) return html
|
|
17
|
+
|
|
18
|
+
let processed = html
|
|
19
|
+
|
|
20
|
+
if (options.tailwind) {
|
|
21
|
+
processed = await compileTailwind(processed)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return juice(processed, {
|
|
25
|
+
removeStyleTags: true,
|
|
26
|
+
preserveMediaQueries: true,
|
|
27
|
+
preserveFontFaces: true,
|
|
28
|
+
preserveKeyFrames: true,
|
|
29
|
+
applyWidthAttributes: true,
|
|
30
|
+
applyHeightAttributes: true,
|
|
31
|
+
applyAttributesTableElements: true,
|
|
32
|
+
})
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Extract Tailwind utility classes from HTML and compile them to CSS,
|
|
37
|
+
* then inject a <style> block for juice to inline.
|
|
38
|
+
*
|
|
39
|
+
* Uses dynamic import — silently skips if tailwindcss is not installed.
|
|
40
|
+
*/
|
|
41
|
+
async function compileTailwind(html: string): Promise<string> {
|
|
42
|
+
try {
|
|
43
|
+
// @ts-ignore: Tailwind is optional
|
|
44
|
+
const { compile } = await import('tailwindcss')
|
|
45
|
+
|
|
46
|
+
const compiler = await compile('@tailwind utilities;')
|
|
47
|
+
const classes = extractClasses(html)
|
|
48
|
+
|
|
49
|
+
if (classes.length === 0) return html
|
|
50
|
+
|
|
51
|
+
const css = compiler.build(classes)
|
|
52
|
+
if (!css) return html
|
|
53
|
+
|
|
54
|
+
const insertPoint = html.indexOf('</head>')
|
|
55
|
+
if (insertPoint !== -1) {
|
|
56
|
+
return html.slice(0, insertPoint) + `<style>${css}</style>` + html.slice(insertPoint)
|
|
57
|
+
}
|
|
58
|
+
return `<style>${css}</style>` + html
|
|
59
|
+
} catch {
|
|
60
|
+
// tailwindcss not installed or API mismatch — skip silently
|
|
61
|
+
return html
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Extract class names from HTML class="..." attributes. */
|
|
66
|
+
function extractClasses(html: string): string[] {
|
|
67
|
+
const classRegex = /class\s*=\s*["']([^"']*)["']/gi
|
|
68
|
+
const classes = new Set<string>()
|
|
69
|
+
|
|
70
|
+
let match
|
|
71
|
+
while ((match = classRegex.exec(html)) !== null) {
|
|
72
|
+
for (const cls of match[1]!.split(/\s+/)) {
|
|
73
|
+
const trimmed = cls.trim()
|
|
74
|
+
if (trimmed) classes.add(trimmed)
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
return [...classes]
|
|
79
|
+
}
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
import MailManager from './mail_manager.ts'
|
|
2
|
+
import { ViewEngine } from '@stravigor/view'
|
|
3
|
+
import { inlineCss } from './css_inliner.ts'
|
|
4
|
+
import Queue from '@stravigor/queue/queue/queue'
|
|
5
|
+
import type { MailMessage, MailResult, MailAttachment } from './types.ts'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Fluent email builder. Returned by `mail.to()`.
|
|
9
|
+
*
|
|
10
|
+
* @example
|
|
11
|
+
* await mail.to('user@example.com')
|
|
12
|
+
* .subject('Welcome!')
|
|
13
|
+
* .template('welcome', { name: 'Alice' })
|
|
14
|
+
* .send()
|
|
15
|
+
*
|
|
16
|
+
* await mail.to(user.email)
|
|
17
|
+
* .from('support@app.com')
|
|
18
|
+
* .subject('Reset Password')
|
|
19
|
+
* .template('reset', { token })
|
|
20
|
+
* .queue()
|
|
21
|
+
*/
|
|
22
|
+
export class PendingMail {
|
|
23
|
+
private _from?: string
|
|
24
|
+
private _to: string | string[]
|
|
25
|
+
private _cc?: string | string[]
|
|
26
|
+
private _bcc?: string | string[]
|
|
27
|
+
private _replyTo?: string
|
|
28
|
+
private _subject = ''
|
|
29
|
+
private _html?: string
|
|
30
|
+
private _text?: string
|
|
31
|
+
private _attachments: MailAttachment[] = []
|
|
32
|
+
private _template?: string
|
|
33
|
+
private _templateData?: Record<string, unknown>
|
|
34
|
+
|
|
35
|
+
constructor(to: string | string[]) {
|
|
36
|
+
this._to = to
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
from(address: string): this {
|
|
40
|
+
this._from = address
|
|
41
|
+
return this
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
cc(address: string | string[]): this {
|
|
45
|
+
this._cc = address
|
|
46
|
+
return this
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
bcc(address: string | string[]): this {
|
|
50
|
+
this._bcc = address
|
|
51
|
+
return this
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
replyTo(address: string): this {
|
|
55
|
+
this._replyTo = address
|
|
56
|
+
return this
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
subject(value: string): this {
|
|
60
|
+
this._subject = value
|
|
61
|
+
return this
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Set raw HTML content (bypasses template rendering). */
|
|
65
|
+
html(value: string): this {
|
|
66
|
+
this._html = value
|
|
67
|
+
return this
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Set plain text content. */
|
|
71
|
+
text(value: string): this {
|
|
72
|
+
this._text = value
|
|
73
|
+
return this
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Use a .strav template. Name is relative to the mail template prefix. */
|
|
77
|
+
template(name: string, data: Record<string, unknown> = {}): this {
|
|
78
|
+
this._template = name
|
|
79
|
+
this._templateData = data
|
|
80
|
+
return this
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
attach(attachment: MailAttachment): this {
|
|
84
|
+
this._attachments.push(attachment)
|
|
85
|
+
return this
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Build the MailMessage, rendering template + inlining CSS if needed. */
|
|
89
|
+
async build(): Promise<MailMessage> {
|
|
90
|
+
const config = MailManager.config
|
|
91
|
+
let html = this._html
|
|
92
|
+
const text = this._text
|
|
93
|
+
|
|
94
|
+
if (this._template) {
|
|
95
|
+
const templateName = `${config.templatePrefix}.${this._template}`
|
|
96
|
+
html = await ViewEngine.instance.render(templateName, this._templateData ?? {})
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (html) {
|
|
100
|
+
html = await inlineCss(html, {
|
|
101
|
+
enabled: config.inlineCss,
|
|
102
|
+
tailwind: config.tailwind,
|
|
103
|
+
})
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
from: this._from ?? config.from,
|
|
108
|
+
to: this._to,
|
|
109
|
+
cc: this._cc,
|
|
110
|
+
bcc: this._bcc,
|
|
111
|
+
replyTo: this._replyTo,
|
|
112
|
+
subject: this._subject,
|
|
113
|
+
html,
|
|
114
|
+
text,
|
|
115
|
+
attachments: this._attachments.length > 0 ? this._attachments : undefined,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/** Send the email immediately via the configured transport. */
|
|
120
|
+
async send(): Promise<MailResult> {
|
|
121
|
+
const message = await this.build()
|
|
122
|
+
return MailManager.transport.send(message)
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Push the email onto the job queue for async sending. */
|
|
126
|
+
async queue(options?: { queue?: string; delay?: number }): Promise<number> {
|
|
127
|
+
const message = await this.build()
|
|
128
|
+
return Queue.push('strav:send-mail', message, options)
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* Mail helper object — the primary API for sending emails.
|
|
134
|
+
*
|
|
135
|
+
* @example
|
|
136
|
+
* import { mail } from '@stravigor/signal/mail'
|
|
137
|
+
*
|
|
138
|
+
* // Fluent builder
|
|
139
|
+
* await mail.to('user@example.com').subject('Hello').template('welcome', { name }).send()
|
|
140
|
+
*
|
|
141
|
+
* // Convenience send
|
|
142
|
+
* await mail.send({ to: 'user@example.com', subject: 'Hello', template: 'welcome', data: { name } })
|
|
143
|
+
*
|
|
144
|
+
* // Raw HTML send
|
|
145
|
+
* await mail.raw({ to: 'user@example.com', subject: 'Hello', html: '<h1>Hi</h1>' })
|
|
146
|
+
*/
|
|
147
|
+
export const mail = {
|
|
148
|
+
/** Start building an email to the given recipient(s). Returns a fluent PendingMail. */
|
|
149
|
+
to(address: string | string[]): PendingMail {
|
|
150
|
+
return new PendingMail(address)
|
|
151
|
+
},
|
|
152
|
+
|
|
153
|
+
/** Send an email using a template. Convenience wrapper for the fluent API. */
|
|
154
|
+
async send(options: {
|
|
155
|
+
to: string | string[]
|
|
156
|
+
from?: string
|
|
157
|
+
cc?: string | string[]
|
|
158
|
+
bcc?: string | string[]
|
|
159
|
+
replyTo?: string
|
|
160
|
+
subject: string
|
|
161
|
+
template: string
|
|
162
|
+
data?: Record<string, unknown>
|
|
163
|
+
attachments?: MailAttachment[]
|
|
164
|
+
}): Promise<MailResult> {
|
|
165
|
+
const pending = new PendingMail(options.to)
|
|
166
|
+
.subject(options.subject)
|
|
167
|
+
.template(options.template, options.data)
|
|
168
|
+
if (options.from) pending.from(options.from)
|
|
169
|
+
if (options.cc) pending.cc(options.cc)
|
|
170
|
+
if (options.bcc) pending.bcc(options.bcc)
|
|
171
|
+
if (options.replyTo) pending.replyTo(options.replyTo)
|
|
172
|
+
if (options.attachments) {
|
|
173
|
+
for (const a of options.attachments) pending.attach(a)
|
|
174
|
+
}
|
|
175
|
+
return pending.send()
|
|
176
|
+
},
|
|
177
|
+
|
|
178
|
+
/** Send a raw email without template rendering. */
|
|
179
|
+
async raw(options: {
|
|
180
|
+
to: string | string[]
|
|
181
|
+
from?: string
|
|
182
|
+
cc?: string | string[]
|
|
183
|
+
bcc?: string | string[]
|
|
184
|
+
replyTo?: string
|
|
185
|
+
subject: string
|
|
186
|
+
html?: string
|
|
187
|
+
text?: string
|
|
188
|
+
attachments?: MailAttachment[]
|
|
189
|
+
}): Promise<MailResult> {
|
|
190
|
+
const pending = new PendingMail(options.to).subject(options.subject)
|
|
191
|
+
if (options.from) pending.from(options.from)
|
|
192
|
+
if (options.html) pending.html(options.html)
|
|
193
|
+
if (options.text) pending.text(options.text)
|
|
194
|
+
if (options.cc) pending.cc(options.cc)
|
|
195
|
+
if (options.bcc) pending.bcc(options.bcc)
|
|
196
|
+
if (options.replyTo) pending.replyTo(options.replyTo)
|
|
197
|
+
if (options.attachments) {
|
|
198
|
+
for (const a of options.attachments) pending.attach(a)
|
|
199
|
+
}
|
|
200
|
+
return pending.send()
|
|
201
|
+
},
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Register the built-in queue handler for async mail sending.
|
|
205
|
+
* Call this in your app bootstrap after Queue is configured.
|
|
206
|
+
*/
|
|
207
|
+
registerQueueHandler(): void {
|
|
208
|
+
Queue.handle<MailMessage>('strav:send-mail', async message => {
|
|
209
|
+
await MailManager.transport.send(message)
|
|
210
|
+
})
|
|
211
|
+
},
|
|
212
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
export { default, default as MailManager } from './mail_manager.ts'
|
|
2
|
+
export { mail, PendingMail } from './helpers.ts'
|
|
3
|
+
export { SmtpTransport } from './transports/smtp_transport.ts'
|
|
4
|
+
export { ResendTransport } from './transports/resend_transport.ts'
|
|
5
|
+
export { SendGridTransport } from './transports/sendgrid_transport.ts'
|
|
6
|
+
export { MailgunTransport } from './transports/mailgun_transport.ts'
|
|
7
|
+
export { AlibabaTransport } from './transports/alibaba_transport.ts'
|
|
8
|
+
export { LogTransport } from './transports/log_transport.ts'
|
|
9
|
+
export { inlineCss } from './css_inliner.ts'
|
|
10
|
+
export type {
|
|
11
|
+
MailTransport,
|
|
12
|
+
MailMessage,
|
|
13
|
+
MailResult,
|
|
14
|
+
MailAttachment,
|
|
15
|
+
MailConfig,
|
|
16
|
+
SmtpConfig,
|
|
17
|
+
ResendConfig,
|
|
18
|
+
SendGridConfig,
|
|
19
|
+
MailgunConfig,
|
|
20
|
+
AlibabaConfig,
|
|
21
|
+
LogConfig,
|
|
22
|
+
} from './types.ts'
|
|
23
|
+
export type { InlinerOptions } from './css_inliner.ts'
|