Skip to content

Mailer dans NestJS

TL;DR@nestjs-modules/mailer wrap nodemailer et 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 :

  1. Découpler HTTP et SMTP. Jamais d'await mailer.send() dans un controller. Toujours await queue.add().
  2. Idempotence. Un retry ne doit pas envoyer deux fois.
  3. Suppression list. Un email qui a hard-bounced n'est plus envoyé.

🛠️ Code minimal

Installation :

bash
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 responsive

Module mailer avec transport SMTP, templates Handlebars et i18n :

ts
// 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) :

ts
// 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 :

ts
// 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.

ts
import { SESClient } from '@aws-sdk/client-ses';
import { aws } from 'nodemailer';

const transport = {
  SES: { ses: new SESClient({ region: 'eu-west-3' }), aws },
};

Pour Postmark :

ts
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.

ts
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.

mjml
<!-- 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é :

hbs
{{!-- 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.

ts
new HandlebarsAdapter({
  t: (key: string, options: { lng: string }) => i18n.t(key, options),
});
hbs
<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 :

  1. Côté queue : jobId déterministe (welcome:${userId}), BullMQ rejette les duplicates.
  2. Côté provider : utiliser le header X-Idempotency-Key (Postmark le supporte nativement, SES non, Resend oui via idempotency-key).
ts
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é).

ts
@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)

ts
// 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.

html
<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/mailer v1.x. API basée sur MailerModule.forRoot() synchrone, support Handlebars / Pug / EJS. nodemailer v6.
  • Nest 8 : @nestjs-modules/mailer v1.6/1.8. Ajout du support forRootAsync propre. Compatibilité ESM partielle.
  • Nest 9 : @nestjs-modules/mailer v1.8/1.9. Améliorations Handlebars (partials, helpers globaux). nodemailer v6 toujours.
  • Nest 10 : @nestjs-modules/mailer v2. Breaking : passage à ESM strict pour certaines parties, HandlebarsAdapter import path changé (@nestjs-modules/mailer/dist/adapters/handlebars.adapter). Compatibilité nodemailer v6.9+.
  • Nest 11 : @nestjs-modules/mailer v2.x stable. Support nodemailer v6.10+. Pas de breaking côté API. Préférer @nestjs/bullmq v10/v11 pour la queue (BullMQ v5+).

Libs tierces à surveiller :

  • nodemailer v6.10 : support OAuth2 SMTP (Gmail Workspace).
  • mjml v4.x : stable, ESM-only depuis v5.
  • bullmq v5 : breaking changes par rapport à v4 (Worker API). Toujours aligner @nestjs/bullmq.
  • @aws-sdk/client-ses v3 : remplace aws-sdk v2.

⚠️ Pitfalls

  1. 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.
  2. Pas d'idempotence. Sur un retry BullMQ, tu envoies 5 fois le même mail. Use jobId déterministe.
  3. from non vérifié SPF/DKIM/DMARC. Tes mails partent dans le spam. SES exige une identity vérifiée. Always set SPF + DKIM + DMARC.
  4. Trop de connexions SMTP. pool: false ouvre une connexion par mail = ton SMTP te ban pour flood. pool: true, maxConnections: 5 minimum.
  5. 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.
hbs
{{!-- échappé (sûr) vs non échappé (dangereux si "bio" vient de l'utilisateur) --}}
<p>{{bio}}</p>
<p>{{{bio}}}</p>
  1. Charset / encoding cassés. Émoji ou caractères accentués qui apparaissent en ?. contentType: 'text/html; charset=utf-8', et toujours fournir un text/plain fallback.
  2. Lien de tracking absolu sans HTTPS. Gmail downgrade ton mail en "non sécurisé". Toujours HTTPS.
  3. Mauvais Reply-To. Tu envoies depuis no-reply@ mais l'user répond et l'équipe support ne voit jamais. Set replyTo: '[email protected]'.
  4. Pas de version texte. Les clients comme Outlook old ou Apple Watch affichent texte seulement. Mailer ratio spam grimpe. Toujours text + html.
  5. Tester en prod sans backup transport. Si SES tombe, plus aucun mail. Configure un fallback (SMTP secondaire ou Postmark).
  6. Envoyer aux suppressed. Tu refais des hard bounces et te fais blacklister par AWS. Toujours vérifier la suppression list.
  7. Mauvais List-Unsubscribe. Gmail/Yahoo (depuis 2024) exigent un header List-Unsubscribe-Post pour les envois bulk. Sans ça, dossier promotions ou spam.

🧪 Testing

Mock transport en unit test

ts
// 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

ts
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 :

bash
docker run -d --name mailpit -p 1025:1025 -p 8025:8025 axllent/mailpit
ts
transport: { 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 :

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.json

Le subject est dans un JSON pour éviter de l'avoir dans le code Nest :

json
{
  "fr": "Bienvenue, {{name}}",
  "en": "Welcome, {{name}}"
}

Le service charge le bon subject + bon template selon locale.

ts
const subjects = require(`../../templates/${template}/${template}.subject.json`);
const compiledSubject = Handlebars.compile(subjects[locale])(context);

Observability mailer

Quatre métriques minimum à exposer en Prometheus :

ts
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 add au complete BullMQ).
  • 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.

ts
// 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 {}
ts
// 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.

ts
// 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 :

  • concurrency basse 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ée ai-emails, séparée de la queue emails critique (welcome, reset password).
  • Cost-aware retry. Un retry naïf attempts: 5 sur 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 lit content[0].text sans vérifier stop_reason crashe 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.

ts
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'API

Exposer 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.

ts
// 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.

ts
@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.

ts
@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.

ts
@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.

ts
// 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

CasRecommandation
Mail transactionnel (welcome, reset password)Queue + provider transactionnel (Postmark, Resend, SES)
Mail marketingOutil 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-langueHandlebars + i18n + un fichier par locale
Mail très visuelMJML 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 from sans 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édiatement 201. 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 sans await 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 jobId tant que le job précédent n'est pas terminé+purgé. Avec removeOnComplete: { 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 provider X-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 set en 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.stream appelé 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.

  1. 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 le 504 côté client alors que le compte est créé. Corrige en repassant en queue.
  2. La tempête de retries. Configure attempts: 10, backoff: { type: 'fixed', delay: 0 } sur un job dont le provider renvoie 421 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 attempts sans backoff transforme un retry en attaque DoS contre ton propre SMTP. Mesure : combien de connexions ouvertes par seconde avant/après ? Ajoute une métrique emails_retries_total pour 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-Post pour les envois > 5000/jour, sinon dossier promotions ou spam. Le nodemailer list: { unsubscribe: { url, comment } } ne pose que List-Unsubscribe — le -Post doit être ajouté à la main dans headers. 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

Bibliothèque tech perso — Achref