Mailer dans NestJS
TL;DR —
@nestjs-modules/mailerwrapnodemaileret ajoute les templates (Handlebars, Pug, EJS). Pour un système production-grade, tu déportes l'envoi en queue (BullMQ), tu gères les bounces / complaints via webhook, tu testes en dev avec MailHog/Mailpit, et tu utilises des templates MJML pour le HTML responsive. Les vrais sujets : idempotence, retry, i18n, observability.
🧠 Mental model
Envoyer un mail n'est jamais "synchrone et fiable". C'est un effet de bord vers un système externe (SMTP, API SES) qui peut :
- timeout
- rate-limit
- refuser (bounce hard/soft)
- accepter puis tagger comme spam
- déclencher une plainte (FBL)
+-------------+ +------------+ +-------------+ +---------+
| controller +----->| mail.queue +----->| processor +----->| provider|
| (HTTP) | | (BullMQ) | | (worker) | | SMTP/API|
+-------------+ +------------+ +------+------+ +----+----+
| |
v v
render template delivery
(MJML/HBS)
|
+--------------------------------+
v
webhook bounces / complaints
v
update user state (suppression list)Analogie : la queue, c'est la poste centrale. Tu déposes ton enveloppe, tu reçois un reçu. Tu ne sais pas si le destinataire ouvrira. Plus tard, un facteur revient avec un "n'habite pas à l'adresse indiquée" : c'est le webhook bounce. Le bon design tient compte de cette boucle de feedback.
Trois principes :
- Découpler HTTP et SMTP. Jamais d'
await mailer.send()dans un controller. Toujoursawait queue.add(). - Idempotence. Un retry ne doit pas envoyer deux fois.
- Suppression list. Un email qui a hard-bounced n'est plus envoyé.
🛠️ Code minimal
Installation :
npm i @nestjs-modules/mailer nodemailer
npm i -D @types/nodemailer
npm i handlebars # ou pug, ejs, mjml
npm i @nestjs/bullmq bullmq ioredis # pour la queue
npm i mjml # templates HTML responsiveModule mailer avec transport SMTP, templates Handlebars et i18n :
// src/mailer/mailer.module.ts
import { Module } from '@nestjs/common';
import { MailerModule } from '@nestjs-modules/mailer';
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { join } from 'node:path';
import { BullModule } from '@nestjs/bullmq';
import { EmailProcessor } from './email.processor';
import { EmailService } from './email.service';
@Module({
imports: [
MailerModule.forRootAsync({
imports: [ConfigModule, I18nModule], // I18nModule pour injecter I18nService
inject: [ConfigService, I18nService],
useFactory: (config: ConfigService, i18n: I18nService) => ({
transport: {
host: config.getOrThrow('SMTP_HOST'),
port: config.get<number>('SMTP_PORT', 587),
secure: config.get<boolean>('SMTP_SECURE', false),
auth: {
user: config.getOrThrow('SMTP_USER'),
pass: config.getOrThrow('SMTP_PASS'),
},
pool: true,
maxConnections: 5,
maxMessages: 100, // recycle la connexion après 100 mails (évite les fuites côté serveur SMTP)
rateLimit: 14, // mails/sec PAR connexion → débit effectif ≈ maxConnections * rateLimit
},
defaults: {
from: `"${config.get('MAIL_FROM_NAME')}" <${config.get('MAIL_FROM_ADDRESS')}>`,
},
template: {
dir: join(process.cwd(), 'templates'),
// `i18n` est maintenant injecté → le helper `t` est correctement scopé (pas de variable libre)
adapter: new HandlebarsAdapter({
t: (key: string, locale: string) => i18n.translate(key, { lang: locale }),
}),
options: { strict: true },
},
}),
}),
BullModule.registerQueue({ name: 'emails' }),
],
providers: [EmailService, EmailProcessor],
exports: [EmailService],
})
export class MailModule {}Service qui enqueue (jamais ne send directement) :
// src/mailer/email.service.ts
import { Injectable } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
export interface SendEmailJob {
template: string;
to: string;
locale: 'fr' | 'en';
context: Record<string, unknown>;
idempotencyKey: string;
}
@Injectable()
export class EmailService {
constructor(@InjectQueue('emails') private queue: Queue<SendEmailJob>) {}
async enqueueWelcome(user: { email: string; name: string; locale: 'fr' | 'en' }) {
await this.queue.add('send', {
template: 'welcome',
to: user.email,
locale: user.locale,
context: { name: user.name },
idempotencyKey: `welcome:${user.email}`,
}, {
jobId: `welcome:${user.email}`, // dedup BullMQ
attempts: 5,
backoff: { type: 'exponential', delay: 5_000 },
removeOnComplete: 1000,
removeOnFail: 5000,
});
}
}Worker qui envoie réellement :
// src/mailer/email.processor.ts
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { MailerService } from '@nestjs-modules/mailer';
import { Job } from 'bullmq';
import { Logger } from '@nestjs/common';
import { SendEmailJob } from './email.service';
@Processor('emails', { concurrency: 5 })
export class EmailProcessor extends WorkerHost {
private readonly logger = new Logger(EmailProcessor.name);
constructor(
private mailer: MailerService,
private suppression: SuppressionListService,
) { super(); }
async process(job: Job<SendEmailJob>) {
const { to, template, context, locale, idempotencyKey } = job.data;
if (await this.suppression.isSuppressed(to)) {
this.logger.warn(`Suppressed ${to}`);
return { skipped: true };
}
const info = await this.mailer.sendMail({
to,
subject: subjectFor(template, locale),
template: `${template}.${locale}`,
context,
headers: {
'X-Idempotency-Key': idempotencyKey,
'X-Message-Tag': template,
},
});
return { messageId: info.messageId };
}
}🎯 Patterns courants
1. Transports interchangeables (SMTP, SES, Postmark, Resend)
nodemailer accepte n'importe quel transport implémentant l'interface. SES SDK fournit createTransport directement.
import { SESClient } from '@aws-sdk/client-ses';
import { aws } from 'nodemailer';
const transport = {
SES: { ses: new SESClient({ region: 'eu-west-3' }), aws },
};Pour Postmark :
import { Client } from 'postmark';
import * as nodemailerPostmark from 'nodemailer-postmark-transport';
const transport = nodemailerPostmark({ auth: { apiKey: process.env.POSTMARK_TOKEN! } });Pour Resend, utilise leur SDK directement (pas nodemailer) ou un transport custom. Beaucoup d'équipes choisissent de wrapper le mailer dans une interface EmailSender pour switcher de provider sans toucher au code métier.
export interface EmailSender {
send(args: { to: string; subject: string; html: string; text: string; tags?: Record<string,string>; idempotencyKey: string }): Promise<{ id: string }>;
}2. Templates MJML compilés au build
Le HTML responsive est un cauchemar manuellement. MJML compile vers du HTML inline-styled compatible avec tous les clients.
<!-- templates/welcome.mjml -->
<mjml>
<mj-body>
<mj-section>
<mj-column>
<mj-text>Bonjour {{name}},</mj-text>
<mj-button href="{{ctaUrl}}">Active ton compte</mj-button>
</mj-column>
</mj-section>
</mj-body>
</mjml>Compile au build (mjml templates/*.mjml -o templates/compiled/), puis Handlebars rend les variables au runtime sur le HTML compilé :
{{!-- les placeholders Handlebars survivent à la compilation MJML --}}
<p>Bonjour {{name}}</p>3. i18n multi-langues
Tu maintiens un fichier par locale : welcome.fr.hbs, welcome.en.hbs. Le service choisit en fonction du user.locale. Si tu utilises i18next ou nestjs-i18n, tu peux passer un helper au template.
new HandlebarsAdapter({
t: (key: string, options: { lng: string }) => i18n.t(key, options),
});<h1>{{t "welcome.title" lng=locale}}</h1>Avantages : un seul fichier MJML, traductions centralisées. Inconvénient : moins lisible pour les non-devs (designers, marketeux).
4. Idempotence
Deux niveaux à blinder :
- Côté queue :
jobIddéterministe (welcome:${userId}), BullMQ rejette les duplicates. - Côté provider : utiliser le header
X-Idempotency-Key(Postmark le supporte nativement, SES non, Resend oui viaidempotency-key).
await this.queue.add('send', payload, {
jobId: `password-reset:${userId}:${tokenHash}`,
attempts: 5,
});Si le user clique 10 fois sur "renvoyer", tu envoies une seule fois.
5. Suppression list automatique sur bounce
Tu maintiens une table email_suppressions(email, reason, suppressed_at). Tu insères dessus :
- hard bounce (5xx code)
- complaint (FBL)
- unsubscribe explicit
Et tu vérifies avant chaque envoi (cache lookup recommandé).
@Injectable()
export class SuppressionListService {
constructor(@Inject(CACHE_MANAGER) private cache: Cache, private repo: SuppressionRepository) {}
async isSuppressed(email: string): Promise<boolean> {
const cached = await this.cache.get<boolean>(`sup:${email}`);
if (cached !== undefined) return cached;
const exists = await this.repo.exists(email);
await this.cache.set(`sup:${email}`, exists, 60 * 60 * 1000);
return exists;
}
}6. Webhooks bounces (SES exemple via SNS)
// src/mailer/webhooks.controller.ts
@Controller('webhooks/ses')
export class SesWebhookController {
@Post()
async handle(@Body() body: any, @Headers('x-amz-sns-message-type') type: string) {
const message = JSON.parse(body.Message);
if (message.notificationType === 'Bounce' && message.bounce.bounceType === 'Permanent') {
for (const r of message.bounce.bouncedRecipients) {
await this.suppression.add(r.emailAddress, 'hard_bounce');
}
}
if (message.notificationType === 'Complaint') {
for (const r of message.complaint.complainedRecipients) {
await this.suppression.add(r.emailAddress, 'complaint');
}
}
return { ok: true };
}
}Postmark fournit des webhooks plus simples (bounce, spam_complaint).
7. Open / click tracking
Provider-side (Postmark, Resend, SES Configuration Sets) ou DIY via un pixel et un endpoint de redirect signed.
<img src="https://api.example.com/track/open?m={{messageId}}" width="1" height="1" />Attention RGPD : si tu traces les opens, il faut une base légale et le mentionner dans la politique de confidentialité. Beaucoup d'équipes ne tracent que les clics sur les CTAs critiques.
🔄 Versions — Nest 7 / 8 / 9 / 10 / 11
- Nest 7 :
@nestjs-modules/mailerv1.x. API basée surMailerModule.forRoot()synchrone, support Handlebars / Pug / EJS.nodemailerv6. - Nest 8 :
@nestjs-modules/mailerv1.6/1.8. Ajout du supportforRootAsyncpropre. Compatibilité ESM partielle. - Nest 9 :
@nestjs-modules/mailerv1.8/1.9. Améliorations Handlebars (partials, helpers globaux).nodemailerv6 toujours. - Nest 10 :
@nestjs-modules/mailerv2. Breaking : passage à ESM strict pour certaines parties,HandlebarsAdapterimport path changé (@nestjs-modules/mailer/dist/adapters/handlebars.adapter). Compatibiliténodemailerv6.9+. - Nest 11 :
@nestjs-modules/mailerv2.x stable. Supportnodemailerv6.10+. Pas de breaking côté API. Préférer@nestjs/bullmqv10/v11 pour la queue (BullMQ v5+).
Libs tierces à surveiller :
nodemailerv6.10 : support OAuth2 SMTP (Gmail Workspace).mjmlv4.x : stable, ESM-only depuis v5.bullmqv5 : breaking changes par rapport à v4 (WorkerAPI). Toujours aligner@nestjs/bullmq.@aws-sdk/client-sesv3 : remplaceaws-sdkv2.
⚠️ Pitfalls
- Envoyer dans le controller HTTP. Si SMTP timeout, tu renvoies 504 à l'user alors que la création du compte a réussi. Toujours en queue.
- Pas d'idempotence. Sur un retry BullMQ, tu envoies 5 fois le même mail. Use
jobIddéterministe. fromnon vérifié SPF/DKIM/DMARC. Tes mails partent dans le spam. SES exige une identity vérifiée. Always set SPF + DKIM + DMARC.- Trop de connexions SMTP.
pool: falseouvre une connexion par mail = ton SMTP te ban pour flood.pool: true, maxConnections: 5minimum. - Templates non échappés. Handlebars échappe par défaut, mais la syntaxe triple-accolade injecte du HTML brut sans échappement. Risque XSS dans les mails reçus.
{{!-- échappé (sûr) vs non échappé (dangereux si "bio" vient de l'utilisateur) --}}
<p>{{bio}}</p>
<p>{{{bio}}}</p>- Charset / encoding cassés. Émoji ou caractères accentués qui apparaissent en
?.contentType: 'text/html; charset=utf-8', et toujours fournir untext/plainfallback. - Lien de tracking absolu sans HTTPS. Gmail downgrade ton mail en "non sécurisé". Toujours HTTPS.
- Mauvais
Reply-To. Tu envoies depuisno-reply@mais l'user répond et l'équipe support ne voit jamais. SetreplyTo: '[email protected]'. - Pas de version texte. Les clients comme Outlook old ou Apple Watch affichent texte seulement. Mailer ratio spam grimpe. Toujours
text+html. - Tester en prod sans backup transport. Si SES tombe, plus aucun mail. Configure un fallback (SMTP secondaire ou Postmark).
- Envoyer aux suppressed. Tu refais des hard bounces et te fais blacklister par AWS. Toujours vérifier la suppression list.
- Mauvais
List-Unsubscribe. Gmail/Yahoo (depuis 2024) exigent un headerList-Unsubscribe-Postpour les envois bulk. Sans ça, dossier promotions ou spam.
🧪 Testing
Mock transport en unit test
// email.service.spec.ts
import { Test } from '@nestjs/testing';
import { EmailService } from './email.service';
import { getQueueToken } from '@nestjs/bullmq';
describe('EmailService', () => {
let service: EmailService;
let queue: { add: jest.Mock };
beforeEach(async () => {
queue = { add: jest.fn() };
const moduleRef = await Test.createTestingModule({
providers: [
EmailService,
{ provide: getQueueToken('emails'), useValue: queue },
],
}).compile();
service = moduleRef.get(EmailService);
});
it('enqueues welcome with deterministic jobId', async () => {
await service.enqueueWelcome({ email: '[email protected]', name: 'A', locale: 'fr' });
expect(queue.add).toHaveBeenCalledWith(
'send',
expect.objectContaining({ template: 'welcome', to: '[email protected]' }),
expect.objectContaining({ jobId: 'welcome:[email protected]' }),
);
});
});Snapshot du HTML rendu
import { MailerService } from '@nestjs-modules/mailer';
describe('welcome template', () => {
let mailer: MailerService;
beforeAll(async () => {
const moduleRef = await Test.createTestingModule({
imports: [MailerModule.forRoot({
transport: { jsonTransport: true }, // ne envoie rien, retourne JSON
template: { dir: 'templates', adapter: new HandlebarsAdapter() },
})],
}).compile();
mailer = moduleRef.get(MailerService);
});
it('renders the welcome template (snapshot)', async () => {
const info: any = await mailer.sendMail({
to: '[email protected]',
subject: 'Welcome',
template: 'welcome.fr',
context: { name: 'Achref' },
});
expect(info.message.toString()).toMatchSnapshot();
});
});MailHog / Mailpit pour le dev
MailHog (legacy, plus maintenu) et Mailpit (successeur) exposent un SMTP local + une UI web. Branchement :
docker run -d --name mailpit -p 1025:1025 -p 8025:8025 axllent/mailpittransport: { host: 'localhost', port: 1025, ignoreTLS: true }Tu vois tous les mails envoyés sur http://localhost:8025. Idéal pour tests e2e avec un vrai render.
Tester un bounce (sans envoyer en vrai)
SES propose des adresses simulator :
[email protected]-> bounce[email protected]-> complaint[email protected]-> success
Tu valides ton pipeline de webhook sans polluer ta réputation.
Templates multi-langues : organisation recommandée
templates/
welcome/
welcome.fr.hbs
welcome.en.hbs
welcome.subject.json
welcome.mjml # source
password-reset/
password-reset.fr.hbs
password-reset.en.hbs
password-reset.subject.jsonLe subject est dans un JSON pour éviter de l'avoir dans le code Nest :
{
"fr": "Bienvenue, {{name}}",
"en": "Welcome, {{name}}"
}Le service charge le bon subject + bon template selon locale.
const subjects = require(`../../templates/${template}/${template}.subject.json`);
const compiledSubject = Handlebars.compile(subjects[locale])(context);Observability mailer
Quatre métriques minimum à exposer en Prometheus :
const sent = new Counter({ name: 'emails_sent_total', labelNames: ['template', 'locale', 'status'] });
const latency = new Histogram({ name: 'emails_send_duration_seconds', labelNames: ['template'] });
const queueDepth = new Gauge({ name: 'emails_queue_depth', labelNames: ['state'] });
const bounces = new Counter({ name: 'emails_bounces_total', labelNames: ['type'] });Dashboards utiles :
- Taux de succès par template et par locale.
- P95 latence d'envoi (du
addaucompleteBullMQ). - Profondeur de queue.
- Ratio bounce / sent (alerte si > 2%).
🤖 Mailer + IA : générer du contenu d'email avec un LLM
C'est le pattern qui transforme un mailer transactionnel en mailer intelligent : un digest hebdomadaire résumé par un LLM, une relance dont le ton est adapté au profil débiteur, un récap d'activité personnalisé. La règle d'or reste la même que pour SMTP — jamais d'appel LLM synchrone dans un controller HTTP. Un appel claude-opus-4-8 à effort high peut prendre 30 secondes à plusieurs minutes ; il doit vivre dans le worker BullMQ, exactement là où vit déjà l'envoi.
Le client LLM est un provider DI, pas un new Anthropic() dans un champ
L'anti-pattern classique : instancier le SDK dans le constructeur du service. Ça casse les tests (pas de mock injectable), duplique la config (clé API lue à 4 endroits), et empêche de partager les retries SDK. Le client se DI via forRootAsync, comme le MailerModule.
// src/llm/llm.module.ts
import { Module, Global } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import Anthropic from '@anthropic-ai/sdk';
export const ANTHROPIC = Symbol('ANTHROPIC');
@Global()
@Module({
imports: [ConfigModule],
providers: [
{
provide: ANTHROPIC,
inject: [ConfigService],
useFactory: (config: ConfigService) =>
new Anthropic({
apiKey: config.getOrThrow('ANTHROPIC_API_KEY'),
maxRetries: 3, // le SDK retry 429/5xx avec backoff exponentiel
timeout: 120_000, // génération longue → laisse de la marge
}),
},
],
exports: [ANTHROPIC],
})
export class LlmModule {}// le service le consomme par injection — testable, mockable
@Injectable()
export class EmailContentService {
constructor(@Inject(ANTHROPIC) private readonly anthropic: Anthropic) {}
}Générer le corps dans le worker, avec idempotence sur la génération
Le piège production : un retry BullMQ relance la génération LLM et ré-envoie le mail. Deux coûts à blinder, pas un seul. La génération est chère (tokens) et non-déterministe — on la mémoïse sur une generationId stable, et on n'envoie qu'après l'avoir persistée.
// src/mailer/ai-digest.processor.ts
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { MailerService } from '@nestjs-modules/mailer';
import { Inject, Logger } from '@nestjs/common';
import { Job } from 'bullmq';
import Anthropic from '@anthropic-ai/sdk';
import { ANTHROPIC } from '../llm/llm.module';
interface DigestJob {
userId: string;
to: string;
events: { title: string; at: string }[];
generationId: string; // déterministe : `digest:${userId}:${weekIso}`
}
@Processor('ai-emails', { concurrency: 3 }) // concurrency basse : l'appel LLM domine
export class AiDigestProcessor extends WorkerHost {
private readonly logger = new Logger(AiDigestProcessor.name);
constructor(
@Inject(ANTHROPIC) private readonly anthropic: Anthropic,
private readonly mailer: MailerService,
private readonly cache: GenerationCacheService, // table OU Redis
private readonly suppression: SuppressionListService,
) { super(); }
async process(job: Job<DigestJob>) {
const { to, events, generationId } = job.data;
if (await this.suppression.isSuppressed(to)) return { skipped: true };
// Idempotence de la GÉNÉRATION : on ne paie pas deux fois les tokens sur un retry
let body = await this.cache.get(generationId);
if (!body) {
body = await this.generate(events, job.token); // job.token → AbortSignal
await this.cache.set(generationId, body); // persisté AVANT l'envoi
}
const info = await this.mailer.sendMail({
to,
subject: 'Votre récap de la semaine',
// le HTML vient du LLM, mais on le rend via un template qui l'encapsule
template: 'ai-digest',
context: { body },
headers: { 'X-Idempotency-Key': generationId, 'X-Generated-By': 'claude-opus-4-8' },
});
return { messageId: info.messageId };
}
private async generate(events: DigestJob['events'], jobToken?: string): Promise<string> {
// Streaming recommandé dès que la sortie peut être longue (évite les timeouts).
// .finalMessage() collecte le message complet sans gérer chaque event.
const stream = this.anthropic.messages.stream({
model: 'claude-opus-4-8',
max_tokens: 4096,
thinking: { type: 'adaptive' }, // adaptatif : le modèle dose sa réflexion
system:
'Tu rédiges un récap hebdomadaire chaleureux et concis en français. ' +
'Réponds en HTML simple (<p>, <ul>, <strong>), sans <html>/<head>, sans préambule.',
messages: [{ role: 'user', content: JSON.stringify(events) }],
});
const msg = await stream.finalMessage();
// Toujours vérifier stop_reason AVANT de lire content : un refus renvoie un 200
if (msg.stop_reason === 'refusal') {
this.logger.warn('LLM refusal on digest generation');
throw new Error('content_refused'); // BullMQ retry/DLQ selon ta policy
}
return msg.content.filter((b) => b.type === 'text').map((b: any) => b.text).join('');
}
}Points senior à expliquer en revue :
concurrencybasse sur la queue IA. Un envoi SMTP tient 5–14/s ; une génération LLM tient quelques req/s et coûte des tokens. Mélanger les deux dans la même queue, c'est laisser l'IA affamer l'envoi transactionnel. Une queue dédiéeai-emails, séparée de la queueemailscritique (welcome, reset password).- Cost-aware retry. Un retry naïf
attempts: 5sur un job IA peut coûter 5× les tokens. On mémoïse la génération (ci-dessus) pour que seuls les retries d'envoi repartent — la génération, elle, n'est payée qu'une fois. Pour les erreurs non-rejouables (refus, 400), on route en DLQ plutôt que de réessayer. stop_reason: 'refusal'est un HTTP 200, pas une exception. Du code qui litcontent[0].textsans vérifierstop_reasoncrashe sur un refus. Toujours brancher dessus.- Le HTML du LLM est du contenu non fiable. On ne l'injecte JAMAIS via triple-accolade Handlebars sans sanitization (DOMPurify côté serveur, ou un sous-ensemble de balises autorisées). Un prompt injection pourrait sinon faire émettre un
<a href>de phishing dans ton mail signé DKIM.
Annuler une génération si le job est retiré (AbortController)
Si l'utilisateur se désinscrit ou si la campagne est annulée pendant qu'un worker génère, il faut couper l'appel LLM en vol — sinon on paie des tokens pour un mail qui ne partira pas. Le SDK accepte un AbortSignal.
const controller = new AbortController();
// brancher sur l'annulation BullMQ (job retiré, campagne stoppée)
const stream = this.anthropic.messages.stream(
{ model: 'claude-opus-4-8', max_tokens: 4096, messages },
{ signal: controller.signal },
);
// ailleurs : controller.abort() coupe la requête HTTP vers l'APIExposer un endpoint d'aperçu en streaming (SSE)
Pour une UI « générer un brouillon de relance », on streame les tokens vers le navigateur en SSE — l'admin voit le texte apparaître au lieu d'attendre 30 s un spinner. Le @Sse de Nest + un Observable suffisent ; on coupe sur déconnexion client.
// src/mailer/draft.controller.ts
import { Controller, Sse, Query, Inject } from '@nestjs/common';
import { Observable } from 'rxjs';
import Anthropic from '@anthropic-ai/sdk';
import { ANTHROPIC } from '../llm/llm.module';
@Controller('admin/email-draft')
export class EmailDraftController {
constructor(@Inject(ANTHROPIC) private readonly anthropic: Anthropic) {}
@Sse('stream')
draft(@Query('invoiceId') invoiceId: string): Observable<{ data: string }> {
return new Observable((subscriber) => {
const controller = new AbortController();
const stream = this.anthropic.messages.stream(
{
model: 'claude-opus-4-8',
max_tokens: 2048,
messages: [{ role: 'user', content: `Rédige une relance pour la facture ${invoiceId}` }],
},
{ signal: controller.signal },
);
stream.on('text', (delta) => subscriber.next({ data: delta }));
stream.on('end', () => subscriber.complete());
stream.on('error', (err) => subscriber.error(err));
// déconnexion client → abort de l'appel LLM (pas de tokens gaspillés)
return () => controller.abort();
});
}
}Au edge de cet endpoint, on met un rate-limit (un admin ne génère pas 1000 brouillons/min) et un cost-guard (compteur de tokens/jour par tenant) — sinon une boucle UI ou un acteur malveillant fait exploser la facture API.
🎬 Cas d'usage concrets
Logiciel de comptabilité — relance d'impayés automatique
Qui : éditeur SaaS comptable servant 5 000 PME françaises. Module de recouvrement qui envoie des relances graduées (J+7, J+15, J+30) sur les factures impayées.
Problème : les emails de relance partaient en synchrone depuis le cron, bloquant la requête pendant 12 minutes quand 3 000 factures arrivaient à échéance simultanément. Aucune visibilité sur les bounces, aucune désinscription gérée.
@Injectable()
export class DunningService {
constructor(
private readonly mailer: MailerService,
@InjectQueue('email') private readonly queue: Queue,
private readonly repo: InvoiceRepository,
) {}
async runDailyDunning() {
const due = await this.repo.findOverdueWithStage();
for (const invoice of due) {
await this.queue.add('send-dunning', {
invoiceId: invoice.id,
stage: invoice.dunningStage,
debtorEmail: invoice.debtorEmail,
}, {
attempts: 5,
backoff: { type: 'exponential', delay: 60_000 },
removeOnComplete: { age: 86400 },
});
}
}
}
@Processor('email')
export class DunningProcessor extends WorkerHost {
async process(job: Job<{ invoiceId: string; stage: number; debtorEmail: string }>) {
const invoice = await this.repo.findOneWithCompany(job.data.invoiceId);
const template = `dunning-stage-${job.data.stage}`;
await this.mailer.sendMail({
to: job.data.debtorEmail,
replyTo: invoice.company.billingEmail,
subject: `Relance ${job.data.stage} - Facture ${invoice.number}`,
template,
context: { invoice, company: invoice.company, dueDate: invoice.dueDate },
attachments: [{ filename: `${invoice.number}.pdf`, content: await this.pdf.render(invoice) }],
});
await this.repo.markDunned(invoice.id, job.data.stage);
}
}Gains : le cron rend la main en 800 ms (juste l'enqueue), workers BullMQ envoient 3 000 mails en 4 minutes en parallèle. Le retry exponentiel absorbe les ralentissements SES, les bounces sont captés via webhook SNS et mis à jour en base. Taux de recouvrement amélioré de 12% grâce à la régularité.
E-commerce mode — newsletter segmentée 200 k abonnés
Qui : marque DNVB de prêt-à-porter, 200 000 abonnés newsletter, 2 envois par semaine. Segmentation par genre, taille, dernier achat, géolocalisation.
Problème : envoyer 200 k mails en quelques minutes via SMTP perso = blocage par les FAI. Il faut une chunking par segment, un throttling par domaine destinataire, et une mesure d'engagement.
@Injectable()
export class NewsletterCampaignService {
async launch(campaignId: string) {
const segments = await this.segmentsRepo.findForCampaign(campaignId);
for (const segment of segments) {
const recipients = await this.contactsRepo.streamBySegment(segment.id);
for await (const batch of chunk(recipients, 500)) {
await this.queue.addBulk(batch.map((r) => ({
name: 'send-newsletter',
data: { campaignId, contactId: r.id, segmentId: segment.id, email: r.email },
opts: {
delay: this.computeDelay(r.email),
attempts: 3,
},
})));
}
}
}
private computeDelay(email: string): number {
const domain = email.split('@')[1];
return this.throttler.nextSlotMs(domain);
}
}Gains : 200 k envois étalés sur 90 minutes au lieu de 10 min concentrées, taux de délivrabilité Gmail/Outlook passé de 78% à 96%. Tracking d'opens/clicks via SendGrid webhooks injecté dans le CDP pour les prochaines segmentations.
Cabinet d'avocats — convocations clients avec accusé de réception
Qui : SaaS de gestion de cabinets juridiques, 800 cabinets clients. Envoi de convocations à audience avec exigence d'accusé de réception et archivage de l'email pour preuve.
Problème : besoin de pouvoir prouver 5 ans plus tard "j'ai envoyé cet email, le client l'a ouvert". Les pièces jointes contiennent des données confidentielles, interdiction d'envoyer en clair.
@Injectable()
export class HearingNotificationService {
async notifyHearing(caseId: string, hearingId: string) {
const hearing = await this.repo.findOneWithCase(hearingId);
const messageId = `<${randomUUID()}@cabinet-saas.legal>`;
const trackingPixel = await this.tracking.createPixel({ messageId, hearingId });
await this.mailer.sendMail({
to: hearing.case.client.email,
from: { name: hearing.case.lawyer.fullName, address: hearing.case.lawyer.email },
subject: `Convocation - Audience du ${hearing.dateFormatted}`,
template: 'hearing-notification',
context: { hearing, trackingPixel: trackingPixel.url },
headers: { 'Message-ID': messageId, 'Disposition-Notification-To': hearing.case.lawyer.email },
attachments: [{
filename: 'convocation.pdf',
content: await this.encryptedPdf.render(hearing, hearing.case.client.publicKey),
}],
});
await this.archive.store({
messageId, caseId, hearingId,
rawMime: await this.lastSentMime(),
sentAt: new Date(),
});
}
}Gains : conformité RGPD et obligations professionnelles respectées (archivage 5 ans), preuve juridique disponible en cas de litige sur la notification. PDF chiffré avec la clé publique du client, le pixel de tracking confirme l'ouverture pour le suivi avocat.
🛠️ Exemple end-to-end
Contexte : plateforme de signature électronique. Workflow complet d'envoi d'une demande de signature avec lien unique signé, tracking, relance J+3 si non signé, gestion des bounces, opt-out.
// src/signature/signature-mailer.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { MailerService } from '@nestjs-modules/mailer';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue, Job } from 'bullmq';
import { randomUUID } from 'node:crypto';
interface SendSignatureRequestInput {
signatureRequestId: string;
signerEmail: string;
signerName: string;
documentTitle: string;
senderName: string;
senderEmail: string;
companyId: string;
}
@Injectable()
export class SignatureMailerService {
private readonly log = new Logger(SignatureMailerService.name);
constructor(
private readonly mailer: MailerService,
@InjectQueue('signature-email') private readonly queue: Queue,
private readonly tokens: SignatureTokenService,
private readonly suppressions: SuppressionListService,
private readonly audit: AuditLogService,
) {}
async enqueueSignatureRequest(input: SendSignatureRequestInput) {
if (await this.suppressions.isSuppressed(input.signerEmail)) {
await this.audit.log('signature.email.skipped', { reason: 'suppressed', ...input });
return { skipped: true };
}
const messageId = `<${randomUUID()}@signature.example.com>`;
const token = await this.tokens.issue({
signatureRequestId: input.signatureRequestId,
signerEmail: input.signerEmail,
ttlHours: 168,
});
await this.queue.add('send-signature-request', { ...input, messageId, token }, {
attempts: 5,
backoff: { type: 'exponential', delay: 30_000 },
removeOnComplete: { age: 86400, count: 5000 },
});
await this.queue.add('reminder-day-3', { ...input, messageId, token }, {
delay: 3 * 24 * 60 * 60 * 1000,
});
return { enqueued: true, messageId };
}
}
@Processor('signature-email')
export class SignatureEmailProcessor extends WorkerHost {
constructor(
private readonly mailer: MailerService,
private readonly repo: SignatureRequestRepository,
private readonly suppressions: SuppressionListService,
private readonly audit: AuditLogService,
) { super(); }
async process(job: Job) {
if (job.name === 'send-signature-request') return this.sendInitial(job.data);
if (job.name === 'reminder-day-3') return this.sendReminder(job.data);
}
private async sendInitial(data: any) {
const signUrl = `${process.env.PUBLIC_URL}/sign/${data.token}`;
await this.mailer.sendMail({
to: { name: data.signerName, address: data.signerEmail },
from: { name: data.senderName, address: '[email protected]' },
replyTo: data.senderEmail,
subject: `${data.senderName} vous demande de signer "${data.documentTitle}"`,
template: 'signature-request',
context: { signerName: data.signerName, senderName: data.senderName, documentTitle: data.documentTitle, signUrl, ttlHours: 168 },
headers: { 'Message-ID': data.messageId, 'X-Company-Id': data.companyId },
list: { unsubscribe: { url: `${process.env.PUBLIC_URL}/unsubscribe/${data.token}` } },
});
await this.audit.log('signature.email.sent', { messageId: data.messageId, signatureRequestId: data.signatureRequestId });
}
private async sendReminder(data: any) {
const req = await this.repo.findOne(data.signatureRequestId);
if (req.status === 'SIGNED' || req.status === 'CANCELLED') return;
if (await this.suppressions.isSuppressed(data.signerEmail)) return;
const signUrl = `${process.env.PUBLIC_URL}/sign/${data.token}`;
await this.mailer.sendMail({
to: data.signerEmail,
from: { name: data.senderName, address: '[email protected]' },
subject: `Rappel : "${data.documentTitle}" attend votre signature`,
template: 'signature-reminder',
context: { signerName: data.signerName, senderName: data.senderName, documentTitle: data.documentTitle, signUrl },
});
await this.audit.log('signature.reminder.sent', { signatureRequestId: data.signatureRequestId });
}
}
@Controller('webhooks/ses')
export class SesWebhookController {
constructor(
private readonly suppressions: SuppressionListService,
private readonly audit: AuditLogService,
) {}
@Post()
@UseGuards(SnsSignatureGuard)
async onSesNotification(@Body() body: any) {
const message = JSON.parse(body.Message);
if (message.notificationType === 'Bounce') {
for (const recipient of message.bounce.bouncedRecipients) {
if (message.bounce.bounceType === 'Permanent') {
await this.suppressions.add(recipient.emailAddress, 'permanent-bounce');
}
await this.audit.log('signature.email.bounced', {
messageId: message.mail.messageId, recipient: recipient.emailAddress, type: message.bounce.bounceType,
});
}
}
if (message.notificationType === 'Complaint') {
for (const recipient of message.complaint.complainedRecipients) {
await this.suppressions.add(recipient.emailAddress, 'complaint');
}
}
return { ok: true };
}
}Enqueue avec token signé TTL 7 jours, envoi initial + reminder J+3 programmé d'un coup, vérification opt-out avant chaque envoi, webhook SES qui alimente la suppression list, audit log complet. Le système absorbe 50 000 demandes de signature par jour avec une délivrabilité Gmail à 98%.
🔁 Quand utiliser / éviter
| Cas | Recommandation |
|---|---|
| Mail transactionnel (welcome, reset password) | Queue + provider transactionnel (Postmark, Resend, SES) |
| Mail marketing | Outil dédié (Mailchimp, Customer.io), pas ton API |
| Notification interne (alerte ops) | Préférer Slack/PagerDuty, mail = secondaire |
| Volume faible (< 100/jour) | SMTP direct ok, mais reste en queue |
| Volume élevé (> 10k/jour) | API provider (SES via SDK direct), pool SMTP saturé |
| Mail multi-langue | Handlebars + i18n + un fichier par locale |
| Mail très visuel | MJML compilé au build |
Évite :
- Envoyer depuis un controller HTTP sans queue.
- Stocker les templates en DB sans cache (chaque envoi = round trip).
- Envoyer des PJ lourdes (> 5 MiB) ; préférer un lien signé.
- Hardcoder le
fromsans validation. - Mélanger transactionnel et marketing dans le même provider/IP (réputation).
🏋️ Exercices
Progression : on construit un pipeline, on le rend production-grade, puis on le casse pour comprendre ses modes de défaillance.
Exercice 1 — Le pipeline minimal découplé
Objectif : envoyer un mail de bienvenue sans jamais bloquer le controller HTTP.
Câble MailerModule.forRootAsync (transport Mailpit en dev), une queue BullMQ emails, un EmailService.enqueueWelcome() et un EmailProcessor. Le POST /signup doit rendre la main en < 50 ms même si le SMTP met 10 s à répondre.
Indice/Solution : le controller appelle
queue.add('send', payload, { jobId: ... })et retourne immédiatement201. Vérifie dans l'UI Mailpit (:8025) que le mail arrive après la réponse HTTP. Mesure le temps de réponse du controller avec et sansawait mailer.sendMail()direct pour internaliser la différence.
Exercice 2 — Idempotence à l'épreuve du double-clic
Objectif : garantir qu'un user qui clique 5× sur « renvoyer le lien » ne reçoit qu'un seul mail.
Ajoute un jobId déterministe (reset:${userId}:${tokenHash}) et un test e2e qui enqueue 5 fois le même job en parallèle, puis assert qu'un seul mail est parti.
Indice/Solution : BullMQ dédoublonne sur
jobIdtant que le job précédent n'est pas terminé+purgé. AvecremoveOnComplete: { age: 60 }, deux envois à 90 s d'intervalle repartent — c'est voulu (nouveau token). Le test doit donc jouer sur la fenêtre temporelle. Discute : où mettre la vraie barrière —jobId, header providerX-Idempotency-Key, ou une contrainte d'unicité en base ? (Réponse senior : les trois, en défense en profondeur.)
Exercice 3 — Suppression list + webhook bounce (production-grade)
Objectif : un email qui a hard-bounce ne doit plus jamais être sollicité.
Implémente SuppressionListService avec cache lecture (TTL 1 h), un SesWebhookController derrière un guard de vérification de signature SNS, et un check isSuppressed() avant chaque envoi (initial et reminder). Teste avec [email protected].
Indice/Solution : le piège est le cache : après un bounce, le
seten base ne suffit pas tant que le cache positif (sup:email = false) n'est pas invalidé. Invalide la clé cache dans le handler webhook. Le guard SNS doit vérifier la signature cryptographique du message, pas juste un header — sinon n'importe qui poste un faux bounce pour DoS tes users.
Exercice 4 — Mailer IA avec idempotence de génération
Objectif : un digest hebdo généré par claude-opus-4-8, où un retry ne re-paie jamais les tokens.
Crée une queue dédiée ai-emails (concurrency 3), un AiDigestProcessor qui DI le client Anthropic via forRootAsync, mémoïse la génération sur generationId, et n'envoie qu'après persistance. Vérifie stop_reason avant de lire le contenu.
Indice/Solution : force un échec d'envoi après la génération (transport qui throw) et observe que le retry réutilise la génération cachée — instrumente un compteur
anthropic.messages.streamappelé pour le prouver (doit rester à 1). Sanitize le HTML retourné par le LLM avant rendu : injecte un event au titre<script>alert(1)</script>et vérifie qu'il n'arrive pas brut dans le mail.
Exercice 5 — Casse-le : le 504 fantôme et la tempête de retries
Objectif : reproduire puis corriger deux incidents production classiques.
- Le 504 fantôme. Remplace l'enqueue par un
await mailer.sendMail()direct dans le controller, mets le SMTP en pause (Mailpit arrêté), et observe le504côté client alors que le compte est créé. Corrige en repassant en queue. - La tempête de retries. Configure
attempts: 10, backoff: { type: 'fixed', delay: 0 }sur un job dont le provider renvoie421 rate limit. Observe la queue marteler le SMTP et te faire bannir. Corrige avec un backoff exponentiel + jitter et une DLQ après épuisement.
Indice/Solution : le 504 illustre pourquoi l'effet de bord externe ne doit jamais être dans le chemin de requête. La tempête illustre que
attemptssans backoff transforme un retry en attaque DoS contre ton propre SMTP. Mesure : combien de connexions ouvertes par seconde avant/après ? Ajoute une métriqueemails_retries_totalpour le voir.
Exercice 6 — Conformité Gmail/Yahoo 2024 (le détail qui tue la délivrabilité)
Objectif : passer le check « bulk sender » de Gmail.
Ajoute SPF + DKIM + DMARC sur le domaine d'envoi, un header List-Unsubscribe et List-Unsubscribe-Post: List-Unsubscribe=One-Click, un endpoint /unsubscribe/:token qui traite le POST one-click, et une version text/plain de chaque mail. Vérifie avec mail-tester.com.
Indice/Solution : depuis février 2024, Gmail/Yahoo exigent
List-Unsubscribe-Postpour les envois > 5000/jour, sinon dossier promotions ou spam. Lenodemailerlist: { unsubscribe: { url, comment } }ne pose queList-Unsubscribe— le-Postdoit être ajouté à la main dansheaders. Score cible mail-tester ≥ 9/10.
🎤 En entretien
Q : Pourquoi ne jamais await mailer.sendMail() dans un controller HTTP ? R : Parce que l'envoi est un effet de bord vers un système externe faillible (timeout, rate-limit, panne SMTP). En synchrone, une lenteur SMTP renvoie un 504 au client alors que l'action métier (création de compte) a réussi — incohérence visible. On découple via une queue : le controller fait queue.add() (idempotent, < 50 ms) et retourne ; un worker absorbe les retries et la latence hors du chemin de requête.
Q : Un retry BullMQ peut-il causer un double envoi, et comment on l'empêche ? R : Oui — un job qui throw après sendMail() réussi sera rejoué et ré-enverra. On blinde à trois niveaux : jobId déterministe (dédoublonnage queue), header provider X-Idempotency-Key (Postmark/Resend le supportent), et idéalement une trace d'envoi en base avec contrainte d'unicité. Pour les jobs IA, on ajoute la mémoïsation de la génération pour qu'un retry d'envoi ne re-paie pas les tokens.
Q : Comment tu sers une génération LLM longue depuis NestJS sans bloquer ni exploser les coûts ? R : L'appel claude-opus-4-8 vit dans un worker BullMQ (queue dédiée, concurrency basse), jamais dans le controller. Le client SDK est DI via forRootAsync (testable, retries SDK partagés, clé API centralisée), pas un new Anthropic() dans un champ. On streame (.stream() + .finalMessage()) pour éviter les timeouts, on vérifie stop_reason: 'refusal' avant de lire le contenu, on mémoïse sur une generationId pour un retry cost-aware, on coupe via AbortController si le job est annulé, et on met rate-limit + cost-guard au edge de tout endpoint d'aperçu SSE.
Q : Mes mails partent en spam alors que le code marche. Par où tu commences ? R : Le code n'est presque jamais le problème — c'est la réputation et l'authentification. Check dans l'ordre : SPF/DKIM/DMARC sur le domaine (sans eux, Gmail downgrade), List-Unsubscribe + List-Unsubscribe-Post (exigés bulk depuis 2024), présence d'une version text/plain, ratio bounce/complaint (un envoi aux suppressed te fait blacklister), et séparation des IP transactionnel vs marketing. mail-tester.com donne un score actionnable en 2 minutes.
🔗 Liens
- Doc officielle Nest : https://docs.nestjs.com/recipes/mailer (référencement variable) ; sinon
@nestjs-modules/mailerREADME. @nestjs-modules/mailer: https://github.com/nest-modules/mailernodemailer: https://nodemailer.com- MJML : https://mjml.io
- Mailpit : https://github.com/axllent/mailpit
- Postmark : https://postmarkapp.com
- Resend : https://resend.com
- AWS SES : https://docs.aws.amazon.com/ses/
- BullMQ : https://docs.bullmq.io
- Yahoo / Gmail sender requirements 2024 : https://blog.google/products/gmail/gmail-security-authentication-spam-protection/
- "Email Authentication" (SPF / DKIM / DMARC) : https://www.cloudflare.com/learning/email-security/dmarc-dkim-spf/