Aller au contenu
4 min de lecture Moustakime KIFIA

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
Un service de notifications transactionnelles avec NestJS et Brevo

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.

Continuer la lecture

Quelques articles relies pour renforcer le maillage interne et prolonger les sujets techniques voisins.

7 min

RabbitMQ dans une plateforme SaaS : où le mettre, et pourquoi

Quand utiliser RabbitMQ dans une plateforme SaaS, quels flux lui confier, et comment s'en servir pour découpler le provisioning, les notifications et certains traitements métier sans event-driver tout le système.

  • Integrations
  • Engineering
  • Architecture
Lire la note