@feathers-baas/plugin-notifications 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/CHANGELOG.md +7 -0
- package/dist/index.cjs +381 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +252 -0
- package/dist/index.d.ts +252 -0
- package/dist/index.js +334 -0
- package/dist/index.js.map +1 -0
- package/package.json +58 -0
- package/src/drivers/brevo.driver.ts +47 -0
- package/src/drivers/resend.driver.ts +51 -0
- package/src/drivers/sendgrid.driver.ts +43 -0
- package/src/drivers/smtp.driver.ts +58 -0
- package/src/index.ts +28 -0
- package/src/notifier.ts +204 -0
- package/src/queue/producer.ts +44 -0
- package/src/queue/worker.ts +66 -0
- package/src/registry.ts +51 -0
- package/src/types.ts +57 -0
- package/test/drivers/brevo.driver.test.ts +115 -0
- package/test/drivers/resend.driver.test.ts +116 -0
- package/test/drivers/sendgrid.driver.test.ts +109 -0
- package/test/drivers/smtp.driver.test.ts +121 -0
- package/test/queue.test.ts +131 -0
- package/test/setup.ts +2 -0
- package/tsconfig.json +9 -0
- package/tsup.config.ts +10 -0
- package/vitest.config.ts +13 -0
package/src/index.ts
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Generated by feathers-baas. Safe to edit.
|
|
2
|
+
export { DriverRegistry } from './registry.js'
|
|
3
|
+
export { createBullMQNotifier, createDirectNotifier } from './notifier.js'
|
|
4
|
+
export type { NotifierFn, BullMQNotifierOptions, DirectNotifierOptions } from './notifier.js'
|
|
5
|
+
|
|
6
|
+
// Queue
|
|
7
|
+
export { createNotificationQueue, enqueueNotification, NOTIFICATION_QUEUE_NAME } from './queue/producer.js'
|
|
8
|
+
export { createNotificationWorker } from './queue/worker.js'
|
|
9
|
+
export type { WorkerOptions } from './queue/worker.js'
|
|
10
|
+
|
|
11
|
+
// Drivers
|
|
12
|
+
export { SMTPDriver } from './drivers/smtp.driver.js'
|
|
13
|
+
export type { SMTPDriverOptions } from './drivers/smtp.driver.js'
|
|
14
|
+
export { ResendDriver } from './drivers/resend.driver.js'
|
|
15
|
+
export type { ResendDriverOptions } from './drivers/resend.driver.js'
|
|
16
|
+
export { SendGridDriver } from './drivers/sendgrid.driver.js'
|
|
17
|
+
export type { SendGridDriverOptions } from './drivers/sendgrid.driver.js'
|
|
18
|
+
export { BrevoDriver } from './drivers/brevo.driver.js'
|
|
19
|
+
export type { BrevoDriverOptions } from './drivers/brevo.driver.js'
|
|
20
|
+
|
|
21
|
+
// Types
|
|
22
|
+
export type {
|
|
23
|
+
Notification,
|
|
24
|
+
NotifierDriver,
|
|
25
|
+
NotificationJob,
|
|
26
|
+
NotificationEvent,
|
|
27
|
+
AuthNotificationEvent,
|
|
28
|
+
} from './types.js'
|
package/src/notifier.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// Generated by feathers-baas. Safe to edit.
|
|
2
|
+
import type { Queue } from 'bullmq'
|
|
3
|
+
import type { NotificationJob, Notification, AuthNotificationEvent } from './types.js'
|
|
4
|
+
import { enqueueNotification } from './queue/producer.js'
|
|
5
|
+
import type { DriverRegistry } from './registry.js'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Auth-management notification types mapped to feathers-baas event keys.
|
|
9
|
+
* feathers-authentication-management passes these type strings to the notifier.
|
|
10
|
+
*/
|
|
11
|
+
const AUTH_MANAGEMENT_EVENT_MAP: Record<string, AuthNotificationEvent> = {
|
|
12
|
+
resendVerifySignup: 'auth.resend-verify-signup',
|
|
13
|
+
verifySignup: 'auth.verify-signup',
|
|
14
|
+
verifySignupSetPassword: 'auth.verify-signup-set-password',
|
|
15
|
+
sendResetPwd: 'auth.send-reset-pwd',
|
|
16
|
+
resetPwd: 'auth.reset-pwd',
|
|
17
|
+
passwordChange: 'auth.password-change',
|
|
18
|
+
identityChange: 'auth.identity-change',
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Build a default Notification payload for each auth event type.
|
|
23
|
+
* Templates use EJS — `<%= variable %>` for output, `<% code %>` for logic.
|
|
24
|
+
*/
|
|
25
|
+
function buildAuthNotification(
|
|
26
|
+
type: AuthNotificationEvent,
|
|
27
|
+
user: Record<string, unknown>,
|
|
28
|
+
appUrl: string,
|
|
29
|
+
): Notification | null {
|
|
30
|
+
const email = user['email'] as string
|
|
31
|
+
if (!email) return null
|
|
32
|
+
|
|
33
|
+
switch (type) {
|
|
34
|
+
case 'auth.verify-signup':
|
|
35
|
+
case 'auth.resend-verify-signup': {
|
|
36
|
+
const token = user['verifyToken'] as string | undefined
|
|
37
|
+
const verifyUrl = `${appUrl}/verify?token=${token ?? ''}`
|
|
38
|
+
return {
|
|
39
|
+
to: email,
|
|
40
|
+
channel: 'email',
|
|
41
|
+
subject: 'Verify your email address',
|
|
42
|
+
bodyTemplate: `
|
|
43
|
+
<h2>Welcome!</h2>
|
|
44
|
+
<p>Click the link below to verify your email address:</p>
|
|
45
|
+
<p><a href="<%= verifyUrl %>">Verify email</a></p>
|
|
46
|
+
<p>This link expires in 24 hours.</p>
|
|
47
|
+
`,
|
|
48
|
+
textTemplate: `Verify your email: <%= verifyUrl %>`,
|
|
49
|
+
data: { verifyUrl, user },
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
case 'auth.send-reset-pwd': {
|
|
54
|
+
const token = user['resetToken'] as string | undefined
|
|
55
|
+
const resetUrl = `${appUrl}/reset-password?token=${token ?? ''}`
|
|
56
|
+
return {
|
|
57
|
+
to: email,
|
|
58
|
+
channel: 'email',
|
|
59
|
+
subject: 'Reset your password',
|
|
60
|
+
bodyTemplate: `
|
|
61
|
+
<h2>Password reset requested</h2>
|
|
62
|
+
<p>Click the link below to reset your password:</p>
|
|
63
|
+
<p><a href="<%= resetUrl %>">Reset password</a></p>
|
|
64
|
+
<p>This link expires in 2 hours. If you did not request this, ignore this email.</p>
|
|
65
|
+
`,
|
|
66
|
+
textTemplate: `Reset your password: <%= resetUrl %>`,
|
|
67
|
+
data: { resetUrl, user },
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
case 'auth.reset-pwd': {
|
|
72
|
+
return {
|
|
73
|
+
to: email,
|
|
74
|
+
channel: 'email',
|
|
75
|
+
subject: 'Your password has been reset',
|
|
76
|
+
bodyTemplate: `<p>Your password has been successfully reset. If you did not do this, please contact support immediately.</p>`,
|
|
77
|
+
textTemplate: `Your password has been reset. If you did not do this, please contact support.`,
|
|
78
|
+
data: { user },
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
case 'auth.password-change': {
|
|
83
|
+
return {
|
|
84
|
+
to: email,
|
|
85
|
+
channel: 'email',
|
|
86
|
+
subject: 'Your password has been changed',
|
|
87
|
+
bodyTemplate: `<p>Your account password was just changed. If this was not you, please contact support immediately.</p>`,
|
|
88
|
+
textTemplate: `Your password was changed. Contact support if this wasn't you.`,
|
|
89
|
+
data: { user },
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
case 'auth.identity-change': {
|
|
94
|
+
return {
|
|
95
|
+
to: email,
|
|
96
|
+
channel: 'email',
|
|
97
|
+
subject: 'Your email address is being changed',
|
|
98
|
+
bodyTemplate: `<p>A request was made to change your email address. Click the link to confirm.</p>`,
|
|
99
|
+
textTemplate: `Confirm your email change by clicking the link in the HTML email.`,
|
|
100
|
+
data: { user },
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
case 'auth.verify-signup-set-password': {
|
|
105
|
+
const token = user['verifyToken'] as string | undefined
|
|
106
|
+
const verifyUrl = `${appUrl}/verify-set-password?token=${token ?? ''}`
|
|
107
|
+
return {
|
|
108
|
+
to: email,
|
|
109
|
+
channel: 'email',
|
|
110
|
+
subject: 'Set your password',
|
|
111
|
+
bodyTemplate: `
|
|
112
|
+
<h2>Set your password</h2>
|
|
113
|
+
<p><a href="<%= verifyUrl %>">Set password</a></p>
|
|
114
|
+
`,
|
|
115
|
+
textTemplate: `Set your password: <%= verifyUrl %>`,
|
|
116
|
+
data: { verifyUrl, user },
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export type NotifierFn = (
|
|
123
|
+
type: string,
|
|
124
|
+
user: Record<string, unknown>,
|
|
125
|
+
notifierOptions: Record<string, unknown>,
|
|
126
|
+
) => Promise<void>
|
|
127
|
+
|
|
128
|
+
export interface BullMQNotifierOptions {
|
|
129
|
+
/** BullMQ Queue instance — from createNotificationQueue(). */
|
|
130
|
+
queue: Queue<NotificationJob>
|
|
131
|
+
/** Driver registry — used to determine which drivers handle each event. */
|
|
132
|
+
registry: DriverRegistry
|
|
133
|
+
/**
|
|
134
|
+
* Public base URL of the app, e.g. https://api.example.com
|
|
135
|
+
* Used to construct verification / reset URLs in email templates.
|
|
136
|
+
*/
|
|
137
|
+
appUrl: string
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* Create a notifier function compatible with feathers-authentication-management.
|
|
142
|
+
*
|
|
143
|
+
* When an auth event fires (verifySignup, sendResetPwd, etc.), this notifier:
|
|
144
|
+
* 1. Maps the event type to a feathers-baas notification event key
|
|
145
|
+
* 2. Looks up all registered drivers for that event
|
|
146
|
+
* 3. Enqueues one BullMQ job per driver
|
|
147
|
+
* 4. Returns immediately — delivery is asynchronous
|
|
148
|
+
*
|
|
149
|
+
* Wire into the app:
|
|
150
|
+
* ```ts
|
|
151
|
+
* app.set('notifier', createBullMQNotifier({ queue, registry, appUrl }))
|
|
152
|
+
* ```
|
|
153
|
+
*/
|
|
154
|
+
export interface DirectNotifierOptions {
|
|
155
|
+
/** Driver registry — used to determine which drivers handle each event. */
|
|
156
|
+
registry: DriverRegistry
|
|
157
|
+
/** Public base URL of the app. */
|
|
158
|
+
appUrl: string
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Create a notifier that sends notifications directly via the driver (no queue).
|
|
163
|
+
* Use this when Redis/BullMQ is not available — suitable for API-based drivers
|
|
164
|
+
* (Brevo, Resend, SendGrid) in development or low-traffic environments.
|
|
165
|
+
*/
|
|
166
|
+
export function createDirectNotifier(opts: DirectNotifierOptions): NotifierFn {
|
|
167
|
+
return async (type, user, _notifierOptions) => {
|
|
168
|
+
const eventKey = AUTH_MANAGEMENT_EVENT_MAP[type]
|
|
169
|
+
if (!eventKey) return
|
|
170
|
+
|
|
171
|
+
const notification = buildAuthNotification(eventKey, user, opts.appUrl)
|
|
172
|
+
if (!notification) return
|
|
173
|
+
|
|
174
|
+
const driverNames = opts.registry.getDriversForEvent(eventKey)
|
|
175
|
+
if (driverNames.length === 0) return
|
|
176
|
+
|
|
177
|
+
await Promise.all(
|
|
178
|
+
driverNames.map(async (driverName) => {
|
|
179
|
+
const driver = opts.registry.getDriver(driverName)
|
|
180
|
+
if (!driver) return
|
|
181
|
+
await driver.send(notification)
|
|
182
|
+
}),
|
|
183
|
+
)
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
export function createBullMQNotifier(opts: BullMQNotifierOptions): NotifierFn {
|
|
188
|
+
return async (type, user, _notifierOptions) => {
|
|
189
|
+
const eventKey = AUTH_MANAGEMENT_EVENT_MAP[type]
|
|
190
|
+
if (!eventKey) return // Unknown event type — no-op
|
|
191
|
+
|
|
192
|
+
const notification = buildAuthNotification(eventKey, user, opts.appUrl)
|
|
193
|
+
if (!notification) return // Missing required user fields — no-op
|
|
194
|
+
|
|
195
|
+
const driverNames = opts.registry.getDriversForEvent(eventKey)
|
|
196
|
+
if (driverNames.length === 0) return // No drivers configured for this event
|
|
197
|
+
|
|
198
|
+
await Promise.all(
|
|
199
|
+
driverNames.map((driverName) =>
|
|
200
|
+
enqueueNotification(opts.queue, { driverName, notification }),
|
|
201
|
+
),
|
|
202
|
+
)
|
|
203
|
+
}
|
|
204
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Generated by feathers-baas. Safe to edit.
|
|
2
|
+
import { Queue } from 'bullmq'
|
|
3
|
+
import type { Redis } from 'ioredis'
|
|
4
|
+
import type { NotificationJob } from '../types.js'
|
|
5
|
+
|
|
6
|
+
export const NOTIFICATION_QUEUE_NAME = 'feathers-baas:notifications'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Create a BullMQ Queue instance for notification jobs.
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* const redis = new Redis(process.env.REDIS_URL)
|
|
14
|
+
* const queue = createNotificationQueue(redis)
|
|
15
|
+
* await enqueueNotification(queue, { driverName: 'smtp', notification: { ... } })
|
|
16
|
+
* ```
|
|
17
|
+
*/
|
|
18
|
+
export function createNotificationQueue(redis: Redis): Queue<NotificationJob> {
|
|
19
|
+
return new Queue<NotificationJob>(NOTIFICATION_QUEUE_NAME, {
|
|
20
|
+
connection: redis,
|
|
21
|
+
defaultJobOptions: {
|
|
22
|
+
attempts: 5,
|
|
23
|
+
backoff: {
|
|
24
|
+
type: 'exponential',
|
|
25
|
+
delay: 1_000, // initial delay: 1s → 2s → 4s → 8s → 16s
|
|
26
|
+
},
|
|
27
|
+
removeOnComplete: { count: 100 },
|
|
28
|
+
removeOnFail: { count: 500 },
|
|
29
|
+
},
|
|
30
|
+
})
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Enqueue a notification job.
|
|
35
|
+
*
|
|
36
|
+
* @param queue BullMQ Queue instance from createNotificationQueue()
|
|
37
|
+
* @param job Job payload { driverName, notification }
|
|
38
|
+
*/
|
|
39
|
+
export async function enqueueNotification(
|
|
40
|
+
queue: Queue<NotificationJob>,
|
|
41
|
+
job: NotificationJob,
|
|
42
|
+
): Promise<void> {
|
|
43
|
+
await queue.add('send', job)
|
|
44
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
// Generated by feathers-baas. Safe to edit.
|
|
2
|
+
import { Worker } from 'bullmq'
|
|
3
|
+
import type { Redis } from 'ioredis'
|
|
4
|
+
import type { DriverRegistry } from '../registry.js'
|
|
5
|
+
import type { NotificationJob } from '../types.js'
|
|
6
|
+
import { NOTIFICATION_QUEUE_NAME } from './producer.js'
|
|
7
|
+
|
|
8
|
+
export interface WorkerOptions {
|
|
9
|
+
/** BullMQ concurrency — number of jobs processed in parallel. */
|
|
10
|
+
concurrency?: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Create and start the BullMQ worker that processes notification jobs.
|
|
15
|
+
*
|
|
16
|
+
* The worker looks up the driver from the registry by name, renders the
|
|
17
|
+
* EJS template, and calls driver.send(). BullMQ handles retries with
|
|
18
|
+
* exponential back-off automatically.
|
|
19
|
+
*
|
|
20
|
+
* Call worker.close() during graceful shutdown to drain in-flight jobs.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```ts
|
|
24
|
+
* const worker = createNotificationWorker(registry, redis, { concurrency: 5 })
|
|
25
|
+
*
|
|
26
|
+
* process.on('SIGTERM', async () => {
|
|
27
|
+
* await worker.close()
|
|
28
|
+
* })
|
|
29
|
+
* ```
|
|
30
|
+
*/
|
|
31
|
+
export function createNotificationWorker(
|
|
32
|
+
registry: DriverRegistry,
|
|
33
|
+
redis: Redis,
|
|
34
|
+
opts: WorkerOptions = {},
|
|
35
|
+
): Worker<NotificationJob> {
|
|
36
|
+
const concurrency = opts.concurrency ?? 5
|
|
37
|
+
|
|
38
|
+
const worker = new Worker<NotificationJob>(
|
|
39
|
+
NOTIFICATION_QUEUE_NAME,
|
|
40
|
+
async (job) => {
|
|
41
|
+
const { driverName, notification } = job.data
|
|
42
|
+
|
|
43
|
+
const driver = registry.getDriver(driverName)
|
|
44
|
+
if (!driver) {
|
|
45
|
+
throw new Error(`Notification driver "${driverName}" is not registered`)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
await driver.send(notification)
|
|
49
|
+
},
|
|
50
|
+
{
|
|
51
|
+
connection: redis,
|
|
52
|
+
concurrency,
|
|
53
|
+
// Don't create a new redis connection per job — reuse the pool
|
|
54
|
+
autorun: true,
|
|
55
|
+
},
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
worker.on('failed', (job, err) => {
|
|
59
|
+
// BullMQ handles retries; this is just for observability
|
|
60
|
+
const jobId = job?.id ?? 'unknown'
|
|
61
|
+
const message = err instanceof Error ? err.message : String(err)
|
|
62
|
+
console.error(`[notifications] Job ${jobId} failed: ${message}`)
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
return worker
|
|
66
|
+
}
|
package/src/registry.ts
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// Generated by feathers-baas. Safe to edit.
|
|
2
|
+
import type { NotifierDriver, NotificationEvent } from './types.js'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* DriverRegistry maps event keys to the list of drivers that should handle
|
|
6
|
+
* that event.
|
|
7
|
+
*
|
|
8
|
+
* A single driver instance can handle multiple events (e.g. the SMTP driver
|
|
9
|
+
* handles all auth.* email events).
|
|
10
|
+
*
|
|
11
|
+
* @example
|
|
12
|
+
* ```ts
|
|
13
|
+
* const registry = new DriverRegistry()
|
|
14
|
+
* registry.registerDriver(smtpDriver)
|
|
15
|
+
* registry.mapEvent('auth.verify-signup', 'smtp')
|
|
16
|
+
* registry.mapEvent('auth.send-reset-pwd', 'smtp')
|
|
17
|
+
* ```
|
|
18
|
+
*/
|
|
19
|
+
export class DriverRegistry {
|
|
20
|
+
private readonly drivers = new Map<string, NotifierDriver>()
|
|
21
|
+
private readonly eventMap = new Map<NotificationEvent, string[]>()
|
|
22
|
+
|
|
23
|
+
/** Register a driver under its name. Overwrites any existing driver with the same name. */
|
|
24
|
+
registerDriver(driver: NotifierDriver): void {
|
|
25
|
+
this.drivers.set(driver.name, driver)
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Map a notification event to a driver name. Multiple calls for the same event add drivers. */
|
|
29
|
+
mapEvent(event: NotificationEvent, driverName: string): void {
|
|
30
|
+
const existing = this.eventMap.get(event) ?? []
|
|
31
|
+
if (!existing.includes(driverName)) {
|
|
32
|
+
existing.push(driverName)
|
|
33
|
+
}
|
|
34
|
+
this.eventMap.set(event, existing)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Look up a driver by name. Returns undefined if not registered. */
|
|
38
|
+
getDriver(name: string): NotifierDriver | undefined {
|
|
39
|
+
return this.drivers.get(name)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Get all driver names mapped to the given event. */
|
|
43
|
+
getDriversForEvent(event: NotificationEvent): string[] {
|
|
44
|
+
return this.eventMap.get(event) ?? []
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** All registered driver names. */
|
|
48
|
+
listDriverNames(): string[] {
|
|
49
|
+
return [...this.drivers.keys()]
|
|
50
|
+
}
|
|
51
|
+
}
|
package/src/types.ts
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Generated by feathers-baas. Safe to edit.
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A single notification to be delivered to one recipient.
|
|
5
|
+
* Templates are EJS strings — the worker renders them just before sending.
|
|
6
|
+
*/
|
|
7
|
+
export interface Notification {
|
|
8
|
+
/** Recipient address — email address, phone number, device token, etc. */
|
|
9
|
+
to: string
|
|
10
|
+
/** Notification channel — determines which driver handles delivery. */
|
|
11
|
+
channel: 'email' | 'sms' | 'push' | 'webhook'
|
|
12
|
+
/** Email subject line (email channel only). */
|
|
13
|
+
subject?: string
|
|
14
|
+
/** EJS template string for the message body. */
|
|
15
|
+
bodyTemplate: string
|
|
16
|
+
/** Variables injected into the EJS template at render time. */
|
|
17
|
+
data: Record<string, unknown>
|
|
18
|
+
/** Optional plain-text fallback for HTML email. */
|
|
19
|
+
textTemplate?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A named notification driver — responsible for delivering one channel.
|
|
24
|
+
* Drivers are registered in the DriverRegistry and looked up by name at
|
|
25
|
+
* job processing time.
|
|
26
|
+
*/
|
|
27
|
+
export interface NotifierDriver {
|
|
28
|
+
/** Unique name used as the registry key (e.g. 'smtp', 'resend'). */
|
|
29
|
+
readonly name: string
|
|
30
|
+
/** Deliver the notification to the recipient. */
|
|
31
|
+
send(notification: Notification): Promise<void>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Payload stored inside each BullMQ job.
|
|
36
|
+
* Worker resolves driverName → NotifierDriver from the registry.
|
|
37
|
+
*/
|
|
38
|
+
export interface NotificationJob {
|
|
39
|
+
driverName: string
|
|
40
|
+
notification: Notification
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Maps auth-management event types to a human-readable event key used in
|
|
45
|
+
* the notification event→driver registry.
|
|
46
|
+
*/
|
|
47
|
+
export type AuthNotificationEvent =
|
|
48
|
+
| 'auth.resend-verify-signup'
|
|
49
|
+
| 'auth.verify-signup'
|
|
50
|
+
| 'auth.verify-signup-set-password'
|
|
51
|
+
| 'auth.send-reset-pwd'
|
|
52
|
+
| 'auth.reset-pwd'
|
|
53
|
+
| 'auth.password-change'
|
|
54
|
+
| 'auth.identity-change'
|
|
55
|
+
|
|
56
|
+
/** Union of all supported notification event keys. */
|
|
57
|
+
export type NotificationEvent = AuthNotificationEvent | string
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
// ─── Mock @getbrevo/brevo ─────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
const mockSendTransacEmail = vi.fn()
|
|
6
|
+
|
|
7
|
+
vi.mock('@getbrevo/brevo', () => {
|
|
8
|
+
return {
|
|
9
|
+
TransactionalEmailsApiApiKeys: { apiKey: 0, partnerKey: 1 },
|
|
10
|
+
TransactionalEmailsApi: vi.fn().mockImplementation(() => ({
|
|
11
|
+
setApiKey: vi.fn(),
|
|
12
|
+
sendTransacEmail: mockSendTransacEmail,
|
|
13
|
+
})),
|
|
14
|
+
SendSmtpEmail: vi.fn().mockImplementation(() => ({})),
|
|
15
|
+
}
|
|
16
|
+
})
|
|
17
|
+
|
|
18
|
+
// ─── Imports after mocking ────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
import { BrevoDriver } from '../../src/drivers/brevo.driver.js'
|
|
21
|
+
import type { Notification } from '../../src/types.js'
|
|
22
|
+
|
|
23
|
+
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
describe('BrevoDriver', () => {
|
|
26
|
+
let driver: BrevoDriver
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
mockSendTransacEmail.mockReset()
|
|
30
|
+
mockSendTransacEmail.mockResolvedValue({ messageId: '<msg-abc@smtp-relay.sendinblue.com>' })
|
|
31
|
+
driver = new BrevoDriver({
|
|
32
|
+
apiKey: 'xkeysib-test',
|
|
33
|
+
fromName: 'Feathers BaaS',
|
|
34
|
+
fromAddress: 'no-reply@example.com',
|
|
35
|
+
})
|
|
36
|
+
})
|
|
37
|
+
|
|
38
|
+
it('has the correct driver name', () => {
|
|
39
|
+
expect(driver.name).toBe('brevo')
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
describe('send', () => {
|
|
43
|
+
it('calls sendTransacEmail once per notification', async () => {
|
|
44
|
+
const notification: Notification = {
|
|
45
|
+
to: 'recipient@example.com',
|
|
46
|
+
channel: 'email',
|
|
47
|
+
subject: 'Welcome!',
|
|
48
|
+
bodyTemplate: '<h1>Hello, <%= name %></h1>',
|
|
49
|
+
data: { name: 'Alice' },
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
await driver.send(notification)
|
|
53
|
+
|
|
54
|
+
expect(mockSendTransacEmail).toHaveBeenCalledOnce()
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('sets htmlContent with rendered EJS', async () => {
|
|
58
|
+
const notification: Notification = {
|
|
59
|
+
to: 'user@example.com',
|
|
60
|
+
channel: 'email',
|
|
61
|
+
bodyTemplate: '<p>Hello, <%= user %>!</p>',
|
|
62
|
+
data: { user: 'Bob' },
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
await driver.send(notification)
|
|
66
|
+
|
|
67
|
+
// The email object is mutated in-place by the driver before calling send
|
|
68
|
+
const emailArg = mockSendTransacEmail.mock.calls[0]?.[0] as Record<string, unknown>
|
|
69
|
+
expect(emailArg['htmlContent']).toBe('<p>Hello, Bob!</p>')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('sets textContent when textTemplate is provided', async () => {
|
|
73
|
+
const notification: Notification = {
|
|
74
|
+
to: 'user@example.com',
|
|
75
|
+
channel: 'email',
|
|
76
|
+
bodyTemplate: '<p><%= msg %></p>',
|
|
77
|
+
textTemplate: 'Plain: <%= msg %>',
|
|
78
|
+
data: { msg: 'hi' },
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
await driver.send(notification)
|
|
82
|
+
|
|
83
|
+
const emailArg = mockSendTransacEmail.mock.calls[0]?.[0] as Record<string, unknown>
|
|
84
|
+
expect(emailArg['textContent']).toBe('Plain: hi')
|
|
85
|
+
})
|
|
86
|
+
|
|
87
|
+
it('does not set textContent when no textTemplate is given', async () => {
|
|
88
|
+
const notification: Notification = {
|
|
89
|
+
to: 'user@example.com',
|
|
90
|
+
channel: 'email',
|
|
91
|
+
bodyTemplate: '<p>HTML only</p>',
|
|
92
|
+
data: {},
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
await driver.send(notification)
|
|
96
|
+
|
|
97
|
+
const emailArg = mockSendTransacEmail.mock.calls[0]?.[0] as Record<string, unknown>
|
|
98
|
+
expect(emailArg['textContent']).toBeUndefined()
|
|
99
|
+
})
|
|
100
|
+
|
|
101
|
+
it('falls back to "(no subject)" when subject is not provided', async () => {
|
|
102
|
+
const notification: Notification = {
|
|
103
|
+
to: 'user@example.com',
|
|
104
|
+
channel: 'email',
|
|
105
|
+
bodyTemplate: 'body',
|
|
106
|
+
data: {},
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
await driver.send(notification)
|
|
110
|
+
|
|
111
|
+
const emailArg = mockSendTransacEmail.mock.calls[0]?.[0] as Record<string, unknown>
|
|
112
|
+
expect(emailArg['subject']).toBe('(no subject)')
|
|
113
|
+
})
|
|
114
|
+
})
|
|
115
|
+
})
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
|
2
|
+
|
|
3
|
+
// ─── Mock resend SDK ──────────────────────────────────────────────────────────
|
|
4
|
+
|
|
5
|
+
const mockEmailsSend = vi.fn()
|
|
6
|
+
|
|
7
|
+
vi.mock('resend', () => ({
|
|
8
|
+
Resend: vi.fn().mockImplementation(() => ({
|
|
9
|
+
emails: { send: mockEmailsSend },
|
|
10
|
+
})),
|
|
11
|
+
}))
|
|
12
|
+
|
|
13
|
+
// ─── Imports after mocking ────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
import { ResendDriver } from '../../src/drivers/resend.driver.js'
|
|
16
|
+
import type { Notification } from '../../src/types.js'
|
|
17
|
+
|
|
18
|
+
// ─── Tests ────────────────────────────────────────────────────────────────────
|
|
19
|
+
|
|
20
|
+
describe('ResendDriver', () => {
|
|
21
|
+
let driver: ResendDriver
|
|
22
|
+
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
mockEmailsSend.mockReset()
|
|
25
|
+
mockEmailsSend.mockResolvedValue({ data: { id: 'msg-abc' }, error: null })
|
|
26
|
+
driver = new ResendDriver({
|
|
27
|
+
apiKey: 're_test_key',
|
|
28
|
+
fromName: 'Feathers BaaS',
|
|
29
|
+
fromAddress: 'no-reply@example.com',
|
|
30
|
+
})
|
|
31
|
+
})
|
|
32
|
+
|
|
33
|
+
it('has the correct driver name', () => {
|
|
34
|
+
expect(driver.name).toBe('resend')
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
describe('send', () => {
|
|
38
|
+
it('calls emails.send with rendered HTML and correct fields', async () => {
|
|
39
|
+
const notification: Notification = {
|
|
40
|
+
to: 'recipient@example.com',
|
|
41
|
+
channel: 'email',
|
|
42
|
+
subject: 'Welcome!',
|
|
43
|
+
bodyTemplate: '<h1>Hello, <%= name %></h1>',
|
|
44
|
+
data: { name: 'Alice' },
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
await driver.send(notification)
|
|
48
|
+
|
|
49
|
+
expect(mockEmailsSend).toHaveBeenCalledOnce()
|
|
50
|
+
const arg = mockEmailsSend.mock.calls[0]?.[0] as Record<string, unknown>
|
|
51
|
+
expect(arg['to']).toEqual(['recipient@example.com'])
|
|
52
|
+
expect(arg['from']).toBe('Feathers BaaS <no-reply@example.com>')
|
|
53
|
+
expect(arg['subject']).toBe('Welcome!')
|
|
54
|
+
expect(arg['html']).toBe('<h1>Hello, Alice</h1>')
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
it('renders the text template when provided', async () => {
|
|
58
|
+
const notification: Notification = {
|
|
59
|
+
to: 'user@example.com',
|
|
60
|
+
channel: 'email',
|
|
61
|
+
bodyTemplate: '<p><%= msg %></p>',
|
|
62
|
+
textTemplate: 'Plain: <%= msg %>',
|
|
63
|
+
data: { msg: 'test' },
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
await driver.send(notification)
|
|
67
|
+
|
|
68
|
+
const arg = mockEmailsSend.mock.calls[0]?.[0] as Record<string, unknown>
|
|
69
|
+
expect(arg['text']).toBe('Plain: test')
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('omits text when no textTemplate is given', async () => {
|
|
73
|
+
const notification: Notification = {
|
|
74
|
+
to: 'user@example.com',
|
|
75
|
+
channel: 'email',
|
|
76
|
+
bodyTemplate: '<p>HTML only</p>',
|
|
77
|
+
data: {},
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
await driver.send(notification)
|
|
81
|
+
|
|
82
|
+
const arg = mockEmailsSend.mock.calls[0]?.[0] as Record<string, unknown>
|
|
83
|
+
expect(arg['text']).toBeUndefined()
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
it('falls back to "(no subject)" when subject is not provided', async () => {
|
|
87
|
+
const notification: Notification = {
|
|
88
|
+
to: 'user@example.com',
|
|
89
|
+
channel: 'email',
|
|
90
|
+
bodyTemplate: 'body',
|
|
91
|
+
data: {},
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
await driver.send(notification)
|
|
95
|
+
|
|
96
|
+
const arg = mockEmailsSend.mock.calls[0]?.[0] as Record<string, unknown>
|
|
97
|
+
expect(arg['subject']).toBe('(no subject)')
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
it('throws when the Resend API returns an error', async () => {
|
|
101
|
+
mockEmailsSend.mockResolvedValueOnce({
|
|
102
|
+
data: null,
|
|
103
|
+
error: { message: 'Invalid API key', name: 'validation_error' },
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
const notification: Notification = {
|
|
107
|
+
to: 'user@example.com',
|
|
108
|
+
channel: 'email',
|
|
109
|
+
bodyTemplate: 'body',
|
|
110
|
+
data: {},
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
await expect(driver.send(notification)).rejects.toThrow(/Invalid API key/)
|
|
114
|
+
})
|
|
115
|
+
})
|
|
116
|
+
})
|