Un service de notifications transactionnelles avec NestJS et Brevo
Comment structurer un service NestJS dédié aux notifications, synchroniser des templates HTML versionés en code vers l'API Brevo, et découpler l'envoi via RabbitMQ.
- Engineering
- Integrations
- Architecture
Le vrai problème n’est pas d’envoyer un email. C’est de garder la cohérence entre ce qu’un service backend veut envoyer et ce que le prestataire d’emailing sait effectivement rendre.
Dans la plupart des architectures, les templates transactionnels vivent dans deux endroits : un dans le code, un dans l’interface du prestataire. L’un dérive de l’autre. Avec le temps, personne ne sait plus lequel est juste.
Le pattern retenu ici inverse ce rapport : les templates sont la source de vérité dans le code. Brevo n’est qu’un runtime d’envoi.
Le problème à résoudre
Une plateforme SaaS avec plusieurs services (API PHP, BFF NestJS, API opérateur) a besoin d’envoyer des emails transactionnels depuis plusieurs points d’entrée : activation de compte, invitation de membre, confirmation de paiement, reset de mot de passe.
Les contraintes qui guident l’architecture :
- les templates doivent être versionés avec le code et reviewés en PR
- le prestataire d’envoi doit pouvoir être changé sans impacter les services callers
- les services callers ne doivent pas connaître la logique Brevo, juste publier un message
La solution retenue est un service NestJS dédié, qui porte à la fois la gestion des templates et l’envoi.
Les templates en code
Chaque template est une fonction TypeScript qui retourne du HTML via mjml ou directement avec des helpers de rendu.
// src/templates/email/member-invitation.ts
import { base, h1, p, button } from "./base";
export const subject =
"Vous avez été invité(e) à rejoindre {{ASSOCIATION_NAME}}";
export function html(): string {
return base([
h1("Bienvenue sur Cercly"),
p(
"{{FIRST_NAME}}, vous avez été invité(e) à rejoindre <strong>{{ASSOCIATION_NAME}}</strong>."
),
button("Accepter l'invitation", "{{INVITATION_URL}}"),
p("Ce lien est valable {{EXPIRY_DAYS}} jours.")
]);
}
Les variables sont des placeholders Brevo ({{VARIABLE}}). Le service les déclare dans l’entité template pour validation à l’envoi.
Le point important : il n’y a pas de fichier HTML, pas de dossier emails/, pas de moteur de template séparé. Les templates sont du code TypeScript, compilables, testables, et reviewables comme le reste.
La synchronisation vers Brevo
Au démarrage du service (ou à la demande), TemplatesService parcourt tous les templates locaux, compare le contenu avec les IDs Brevo stockés en base, et crée ou met à jour.
// src/templates/templates.service.ts
async syncAll(): Promise<void> {
for (const [slug, mod] of Object.entries(EMAIL_TEMPLATES)) {
const template = await this.repo.findOneBy({ slug });
const htmlBody = mod.html();
const brevoId = await this.brevo.syncTemplate(
template?.brevoId ?? null,
template?.name ?? slug,
mod.subject,
htmlBody,
);
await this.repo.upsert({ slug, brevoId, htmlBody, subject: mod.subject }, ['slug']);
}
}
BrevoService utilise le SDK @getbrevo/brevo v6, qui n’a aucune dépendance transitive — ce qui élimine les vulnérabilités héritées des versions précédentes.
// src/brevo/brevo.service.ts
async syncTemplate(brevoId: number | null, name: string, subject: string, htmlBody: string): Promise<number> {
const sender = {
email: this.config.getOrThrow('BREVO_SENDER_EMAIL'),
name: this.config.get('BREVO_SENDER_NAME', 'Cercly'),
};
if (brevoId) {
await this.client.transactionalEmails.updateSmtpTemplate({
templateId: brevoId, templateName: name, subject, htmlContent: htmlBody, sender, isActive: true,
});
return brevoId;
}
const created = await this.client.transactionalEmails.createSmtpTemplate({
templateName: name, subject, htmlContent: htmlBody, sender, isActive: true,
});
return created.id;
}
L’ID Brevo est persisté en base. Lors des syncs suivantes, updateSmtpTemplate est appelé avec l’ID connu. Si le template n’existe pas encore côté Brevo, createSmtpTemplate est utilisé et l’ID retourné est sauvegardé.
Le découplage via RabbitMQ
Les services callers (operator-api, platform-api) ne parlent pas directement au service de notifications. Ils publient un message dans un exchange RabbitMQ.
// dans operator-api, côté publisher
await this.amqp.publish("notifications", "notification.send", {
templateId: NOTIF_TEMPLATE_MEMBER_INVITATION,
recipient: { email: member.email, name: member.fullName },
params: {
FIRST_NAME: member.firstName,
ASSOCIATION_NAME: association.name,
INVITATION_URL: invitationUrl,
EXPIRY_DAYS: "7"
}
});
Le consumer dans notification-api valide le message entrant via un DTO class-validator, puis délègue à NotificationsService.
// src/notifications/notifications.consumer.ts
@RabbitSubscribe({ exchange: 'notifications', routingKey: 'notification.send', queue: 'notification.send' })
async handleSend(payload: unknown): Promise<Nack | void> {
const dto = await validateDto(SendNotificationDto, payload);
if (!dto) return new Nack(false); // message invalide, pas de requeue
try {
await this.notifications.send(dto);
} catch {
return new Nack(true); // erreur transitoire, requeue
}
}
La distinction entre Nack(false) et Nack(true) est volontaire : un message malformé ne doit pas être remis en queue indéfiniment. Une erreur réseau vers Brevo, si.
Ce que ça simplifie côté callers
Les services callers n’ont besoin que des UUIDs de templates. Ces identifiants sont fixés par la migration seed et déclarés dans leur propre .env :
NOTIF_TEMPLATE_MEMBER_INVITATION=11111111-1111-1111-1111-111111111111
NOTIF_TEMPLATE_PAYMENT_CONFIRMED=11111111-1111-1111-1111-111111111102
Un service qui veut notifier publie un message, point. Il ne sait rien de Brevo, rien des IDs numériques de templates, rien du SDK. Si le prestataire d’envoi change demain, seul notification-api est modifié.
L’endpoint de preview en développement
Pour itérer sur les templates sans déployer vers Brevo, le service expose une route /api/preview qui rend directement le HTML dans le navigateur.
GET /api/preview/member-invitation
Chaque template y est affiché avec des valeurs d’exemple interpolées, ce qui permet de valider le rendu sans dépendre de l’environnement Brevo de staging.
Ce que ce service ne fait pas
Quelques choix délibérément hors périmètre :
- pas de retry automatique côté service (RabbitMQ porte le requeue)
- pas de templating dynamique côté service (les variables sont résolues par Brevo à l’envoi)
- pas de gestion de préférences utilisateur (pas de désinscription, pas de catégories)
Ce sont des problèmes qui peuvent venir plus tard, si besoin. Pour l’instant le service est délibérément petit.