@mostajs/notifications
v0.1.0
Published
Generic notification engine for @mostajs/* — domain-agnostic templating + i18n + mailer dispatch + idempotence. Consume any event kind from any source (booking, media-sfu, custom apps). Plug-and-play with @mostajs/mailer.
Readme
@mostajs/notifications
Auteur : Dr Hamid MADANI [email protected] License : AGPL-3.0-or-later Version : 0.1.0
Moteur générique de notifications pour l'écosystème @mostajs/* — découple le dispatch (mailer + i18n + templating + idempotence) du domaine métier (booking, media-sfu, custom apps). Tout domaine peut envoyer des notifications en appelant engine.notify(kind, context) ; les adapters spécifiques (@mostajs/booking-notifications, @mostajs/media-sfu-notifications, …) sont de fins glues qui mappent leurs events internes en kinds génériques.
📊 Étude état de l'art (Knock, Courier, Novu, SendGrid, Postmark, Resend, Customer.io, …) → docs/STATE-OF-THE-ART.md
Table des matières
- Pourquoi un moteur générique
- Architecture
- Quick start — how to use
- API détaillée
- Templates — patterns recommandés
- Implémentation — how to impl
- I18n & locales
- Idempotence
- Tests
- Modules liés
1. Pourquoi un moteur générique
Avant ce module, chaque domaine @mostajs/* qui voulait envoyer un mail dupliquait :
- Templates locale (FR / EN / AR)
- Dispatch via
@mostajs/mailer - Idempotence (
messageId) - Retry / fallback / callbacks
Avec ce module :
@mostajs/booking-notifications: 100 LOC de glue (subscribe events booking → notify)@mostajs/media-sfu-notifications(futur) : 80 LOC de glue (subscribe events SFU → notify)@mostajs/orphan-care-notifications(futur) : idem- Plomberie commune (templates registry, mailer, i18n, idempotence) : un seul code à maintenir
Cas où ne PAS utiliser ce module :
- App très simple avec 1 seul mail à envoyer → utilise
@mostajs/mailerdirectement - Domaine très spécifique avec templates DB-driven (CMS-like) → roll your own avec
@mostajs/mailer
2. Architecture
┌──────────────────────────────────────────────────────────────┐
│ App / Adapter consumer │
│ - subscribe events @mostajs/booking, @mostajs/media-sfu, … │
│ - map event → { kind: 'booking.reservation.created', │
│ context: { reservation, slot, ... } } │
│ - appelle engine.notify(...) │
└──────────────────────────────────┬───────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ @mostajs/notifications — NotificationEngine │
│ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ Templates │ │ Locale picker │ │
│ │ registry │ ◀─│ ctx.locale → │ │
│ │ (kind → tpl) │ │ variant FR/EN/AR │ │
│ └────────┬─────────┘ └──────────────────┘ │
│ ▼ │
│ ┌──────────────────┐ ┌──────────────────┐ │
│ │ render(ctx) │ → │ Mail (subject, │ │
│ │ → RenderedMail │ │ to, html, text) │ │
│ └────────┬─────────┘ └────────┬─────────┘ │
│ ▼ ▼ │
│ ┌──────────────────────────────────────────┐ │
│ │ Idempotence (messageId) │ │
│ │ Callbacks (onSent / onError) │ │
│ └────────────────────┬─────────────────────┘ │
└────────────────────────┼─────────────────────────────────────┘
│
▼
┌──────────────────────────────────────────────────────────────┐
│ @mostajs/mailer — driver-based dispatch │
│ SMTP / Resend / Postmark / SES / Brevo / Console / Mock │
└──────────────────────────────────────────────────────────────┘3. Quick start — how to use
Installation
npm install @mostajs/notifications @mostajs/mailerBootstrap minimal
import { createNotificationEngine, simpleTemplate, localizedTemplate } from '@mostajs/notifications'
import { createMailer, createConsoleDriver } from '@mostajs/mailer'
const mailer = createMailer({ driver: createConsoleDriver() })
const engine = createNotificationEngine({
mailer,
defaultFrom: '[email protected]',
defaultLocale: 'fr',
templates: {
'welcome': simpleTemplate({
to: ctx => (ctx.user as any)?.email ?? null,
subject: ctx => `Bienvenue ${(ctx.user as any)?.name}`,
html: ctx => `<h1>Bonjour ${(ctx.user as any)?.name}</h1><p>Heureux de vous accueillir !</p>`,
}),
},
})
await engine.notify('welcome', {
user: { name: 'Amina', email: '[email protected]' },
})Template multi-locale (FR + EN + AR)
engine.register('appointment.reminder', localizedTemplate({
fr: {
subject: ctx => `Rappel : votre rendez-vous ${formatDate(ctx.startAt)}`,
html: ctx => `<p>Bonjour, n'oubliez pas votre rendez-vous demain à <strong>${formatDate(ctx.startAt)}</strong>.</p>`,
},
en: {
subject: ctx => `Reminder: your appointment ${formatDate(ctx.startAt)}`,
html: ctx => `<p>Hi, don't forget your appointment tomorrow at <strong>${formatDate(ctx.startAt)}</strong>.</p>`,
},
ar: {
subject: ctx => `تذكير: موعدكم ${formatDate(ctx.startAt)}`,
html: ctx => `<p>تذكير بموعدكم غداً في <strong>${formatDate(ctx.startAt)}</strong>.</p>`,
},
}, ctx => (ctx.user as any)?.email,
ctx => `appt-reminder-${(ctx as any).reservationId}-24h`, // idempotence
))
// L'app envoie selon la locale du user :
await engine.notify('appointment.reminder', {
user: { email: '[email protected]' },
startAt: Date.now() + 86400_000,
reservationId: 'res-123',
locale: 'ar',
})Dans un adapter domain (@mostajs/booking-notifications, etc.)
// @mostajs/booking-notifications fait :
manager.onEvent = async (ev) => {
if (ev.type !== 'reservation.created') return
const r = await manager.findReservation(ev.reservationId!)
const slot = await manager.findSlot(r.slotId)
await engine.notify('booking.reservation.created', {
reservation: r, slot, locale: r.metadata?.locale,
})
}4. API détaillée
createNotificationEngine(opts) → NotificationEngine
interface NotificationEngineOptions {
mailer: { send: (mail: any) => Promise<{ messageId: string }> }
templates?: Record<string, NotificationTemplate>
defaultLocale?: string
defaultFrom?: string
disabled?: string[]
onSent?: (kind, context, result) => void | Promise<void>
onError?: (kind, context, error) => void | Promise<void>
}
interface NotificationEngine {
register(kind: string, template: NotificationTemplate): void
unregister(kind: string): void
registeredKinds(): string[]
setEnabled(kind: string, enabled: boolean): void
notify(kind: string, context: NotificationContext): Promise<{ messageId: string } | null>
notifyBatch(items: Array<{ kind, context }>): Promise<Array<{ messageId: string } | null>>
}NotificationTemplate
interface NotificationTemplate {
render(context: NotificationContext): Promise<RenderedMail | null> | RenderedMail | null
}
interface RenderedMail {
to: string // requis (sinon skip)
cc?: string | string[]
bcc?: string | string[]
from?: string // override defaultFrom
subject: string
text?: string
html?: string
messageId?: string // idempotence (mailer skip si déjà sent)
metadata?: Record<string, unknown>
}Helpers
simpleTemplate({ to, subject, text?, html?, messageId? })— factory function-basedlocalizedTemplate(variants, resolveTo, resolveMessageId?)— multi-locale avec fallback fr → en
5. Templates — patterns recommandés
Pattern 1 — Templates inline (mini app)
templates: {
'welcome': simpleTemplate({
to: ctx => (ctx.user as any).email,
subject: () => 'Bienvenue',
html: ctx => `<h1>Hello ${(ctx.user as any).name}</h1>`,
}),
}Pattern 2 — Templates dans des fichiers séparés
my-app/
├── lib/
│ └── notifications.ts ← createNotificationEngine + register
└── templates/
├── welcome.ts
├── booking-created.ts
└── ...// templates/welcome.ts
import { localizedTemplate } from '@mostajs/notifications'
export const welcomeTemplate = localizedTemplate({
fr: { subject: 'Bienvenue', html: ctx => `<h1>Hello ${(ctx.user as any).name}</h1>` },
en: { subject: 'Welcome', html: ctx => `<h1>Hello ${(ctx.user as any).name}</h1>` },
}, ctx => (ctx.user as any).email)// lib/notifications.ts
import { welcomeTemplate } from '../templates/welcome.js'
const engine = createNotificationEngine({ mailer, templates: { welcome: welcomeTemplate } })Pattern 3 — Templates DB-driven (CMS)
Pour permettre à des non-devs d'éditer les templates :
const engine = createNotificationEngine({ mailer })
async function loadTemplates() {
const rows = await db.select().from('notification_templates')
for (const row of rows) {
engine.register(row.kind, simpleTemplate({
to: ctx => evalExpr(row.toExpr, ctx),
subject: ctx => interpolate(row.subject, ctx),
html: ctx => interpolate(row.htmlBody, ctx),
}))
}
}
await loadTemplates()
db.on('change:notification_templates', loadTemplates) // hot-reloadPattern 4 — Moteur de template externe (Handlebars, EJS)
import Handlebars from 'handlebars'
const subjectTpl = Handlebars.compile('Rappel : {{appointmentName}} le {{startAt}}')
const htmlTpl = Handlebars.compile(htmlTemplate)
engine.register('reminder', simpleTemplate({
to: ctx => (ctx.user as any).email,
subject: ctx => subjectTpl(ctx),
html: ctx => htmlTpl(ctx),
}))6. Implémentation — how to impl
Adapter pour un domaine (pattern type)
// my-domain-notifications.ts
import type { NotificationEngine } from '@mostajs/notifications'
import { localizedTemplate } from '@mostajs/notifications'
import type { MyManager, MyEvent } from '@mostajs/my-domain'
export function createMyDomainNotifications(opts: {
engine: NotificationEngine
manager: MyManager
/** Prefix pour les kinds : 'my-domain' → 'my-domain.event-x'. Default = nom du domain. */
kindPrefix?: string
}) {
const prefix = opts.kindPrefix ?? 'my-domain'
// 1. Enregistrer les templates par défaut du domaine
opts.engine.register(`${prefix}.welcome`, localizedTemplate({
fr: { subject: 'Bienvenue dans mon domaine', html: ctx => `...` },
en: { subject: 'Welcome', html: ctx => `...` },
}, ctx => (ctx.entity as any)?.email))
// 2. Subscribe aux events du manager → notify
opts.manager.onEvent = async (ev: MyEvent) => {
switch (ev.type) {
case 'entity.created':
const e = await opts.manager.findEntity(ev.entityId!)
await opts.engine.notify(`${prefix}.welcome`, { entity: e, locale: e.locale })
break
// ...
}
}
return {
/** Permet à l'app d'override les templates par défaut. */
setTemplate(kind: string, template: NotificationTemplate) {
opts.engine.register(`${prefix}.${kind}`, template)
},
}
}Routes admin (Next.js)
// app/api/admin/notifications/test/route.ts
import { engine } from '@/lib/notifications'
export async function POST(req: Request) {
const { kind, context } = await req.json()
const result = await engine.notify(kind, context)
return Response.json({ sent: !!result, messageId: result?.messageId })
}
// app/api/admin/notifications/kinds/route.ts
export async function GET() {
return Response.json({ kinds: engine.registeredKinds() })
}7. I18n & locales
Le context.locale détermine quel template variant est choisi. Helper localizedTemplate(variants, ...) :
- Cherche
variants[locale](ex.fr-DZ) - Fallback sur
variants[locale.split('-')[0]](ex.fr) - Fallback final sur
variants.frpuisvariants.en
Résolution de la locale : c'est à l'app de mettre ctx.locale (depuis user.locale DB, Accept-Language header, ou subdomain fr.app.com).
await engine.notify('reminder', {
user: u,
locale: u.locale ?? extractFromHeaders(req) ?? 'fr',
})8. Idempotence
Pour éviter d'envoyer 2× le même reminder 24h si un cron retrigger :
- Le template doit fournir un
messageIddéterministe :reminder-24h-${reservation.id} @mostajs/mailer(Phase 2 MailLog) persistera ce messageId en DB → 2e send avec même ID = no-op silencieux
localizedTemplate(
{ fr: { subject: 'Rappel', html: ... } },
ctx => (ctx.user as any).email,
ctx => `reminder-24h-${(ctx.reservation as any).id}`, // messageId
)9. Tests
// tests/engine.test.ts
import { createNotificationEngine, simpleTemplate } from '@mostajs/notifications'
describe('NotificationEngine', () => {
it('dispatch template registered', async () => {
const sent: any[] = []
const mailer = { send: async (m: any) => { sent.push(m); return { messageId: 'x-1' } } }
const engine = createNotificationEngine({ mailer })
engine.register('welcome', simpleTemplate({
to: _ => '[email protected]',
subject: _ => 'Hello',
text: _ => 'Welcome!',
}))
const r = await engine.notify('welcome', { user: 'x' })
expect(r?.messageId).toBe('x-1')
expect(sent[0]).toMatchObject({ to: '[email protected]', subject: 'Hello' })
})
it('skip if template absent', async () => {
const engine = createNotificationEngine({ mailer: { send: async () => ({ messageId: '' }) } })
expect(await engine.notify('unknown', {})).toBeNull()
})
it('locale fallback', async () => {
// ... test localizedTemplate avec variant manquante
})
})10. Modules liés
@mostajs/mailer— peer dep, dispatch SMTP/Resend/SES/etc.@mostajs/booking-notifications— adapter booking events → notifications- (futur)
@mostajs/media-sfu-notifications— adapter SFU events @mostajs/url— pour construire des liens dans les templates (signedUrlpour invite tokens)
License : AGPL-3.0-or-later Auteur : Dr Hamid MADANI [email protected]
