Envoyer des emails avec Nodemailer dans NestJS

Envoyer des emails avec Nodemailer dans NestJS

L'envoi d'emails est une fonctionnalité incontournable de la plupart des applications web modernes : confirmation d'inscription, réinitialisation de mot de passe, notifications de commande, rappels de rendez-vous… Dans cet article, nous allons construire un module d'emailing complet avec NestJS, Nodemailer et Handlebars, en s'appuyant sur l'EmailModule du projet site-voyance comme référence.

Prérequis

  • Une application NestJS opérationnelle (version 10+)
  • Node.js 18 ou supérieur
  • Le module @nestjs/config installé pour gérer les variables d'environnement
  • Un compte SMTP de test (Mailtrap ou Ethereal recommandés en développement)

Installation des dépendances

Trois paquets sont nécessaires : @nestjs-modules/mailer qui fournit l'intégration NestJS, nodemailer qui gère l'envoi SMTP, et handlebars pour les templates dynamiques.

npm install @nestjs-modules/mailer nodemailer handlebars
npm install -D @types/nodemailer

Configuration des variables d'environnement

Plutôt que de coder en dur les identifiants SMTP, on les expose via des variables d'environnement. Voici un extrait de configuration similaire à celui utilisé dans src/config/configuration.ts :

// src/config/configuration.ts
export default () => ({
  email: {
    host: process.env.EMAIL_HOST || 'smtp.gmail.com',
    port: parseInt(process.env.EMAIL_PORT || '587', 10),
    secure: process.env.EMAIL_SECURE === 'true',
    user: process.env.EMAIL_USER,
    password: process.env.EMAIL_PASSWORD,
    from: process.env.EMAIL_FROM || 'Le Murmure des Cartes <contact@example.fr>',
  },
});

Et le fichier .env correspondant :

EMAIL_HOST=smtp.mailtrap.io
EMAIL_PORT=2525
EMAIL_SECURE=false
EMAIL_USER=votre_user_smtp
EMAIL_PASSWORD=votre_password_smtp
EMAIL_FROM="Mon App <noreply@monapp.fr>"
Astuce : utilisez EMAIL_SECURE=true uniquement avec le port 465 (SSL implicite). Le port 587 fonctionne avec STARTTLS et donc secure=false.

Création du EmailModule

Le module configure MailerModule de manière asynchrone pour récupérer la configuration via ConfigService. Le HandlebarsAdapter est branché pour permettre l'utilisation de templates .hbs.

// src/modules/email/email.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 'path';
import { EmailService } from './email.service';

@Module({
  imports: [
    MailerModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (configService: ConfigService) => ({
        transport: {
          host: configService.get<string>('email.host'),
          port: configService.get<number>('email.port'),
          secure: configService.get<boolean>('email.secure'),
          auth: {
            user: configService.get<string>('email.user'),
            pass: configService.get<string>('email.password'),
          },
        },
        defaults: {
          from: configService.get<string>('email.from'),
        },
        template: {
          dir: join(__dirname, 'templates'),
          adapter: new HandlebarsAdapter(),
          options: { strict: true },
        },
      }),
    }),
  ],
  providers: [EmailService],
  exports: [EmailService],
})
export class EmailModule {}

Quelques points importants :

  • forRootAsync permet d'injecter le ConfigService et donc de lire les variables d'environnement.
  • defaults.from définit l'expéditeur par défaut, évitant de le répéter à chaque envoi.
  • template.dir pointe vers le dossier templates/ au sein du module compilé. Il faudra penser à copier les fichiers .hbs lors du build.
  • strict: true fait échouer le rendu si une variable utilisée dans le template n'est pas fournie : très utile pour détecter les erreurs tôt.

Copier les templates au build

Par défaut, NestJS ne copie que les fichiers TypeScript. Ajoutez ceci dans nest-cli.json pour inclure les templates :

{
  "compilerOptions": {
    "assets": [
      { "include": "modules/email/templates/**/*", "outDir": "dist" }
    ],
    "watchAssets": true
  }
}

Création des templates Handlebars

Créez le dossier src/modules/email/templates/ et ajoutez vos fichiers .hbs. Voici un exemple de template booking-confirmation.hbs :

<!DOCTYPE html>
<html>
  <body style="font-family: Arial, sans-serif; color: #333;">
    <h1>Bonjour {{customerName}},</h1>

    {{#if isAppointment}}
      <p>Votre rendez-vous pour <strong>{{serviceName}}</strong> est confirmé.</p>
      <p>Date : {{date}} à {{time}}</p>
    {{else}}
      <p>Votre commande <strong>{{serviceName}}</strong> a bien été enregistrée.</p>
      {{#if question}}<p>Votre question : {{question}}</p>{{/if}}
    {{/if}}

    <p>Montant : <strong>{{formattedAmount}} €</strong></p>

    <hr>
    <small>© {{year}} - Le Murmure des Cartes</small>
  </body>
</html>

Handlebars supporte les conditions ({{#if}}), les boucles ({{#each}}) et les helpers personnalisés. Le contexte passé depuis le service est directement utilisable dans le template.

Le EmailService

Toute la logique d'envoi est centralisée dans un service dédié, ce qui facilite la maintenance et les tests. Voici le squelette inspiré du projet site-voyance :

// src/modules/email/email.service.ts
import { Injectable } from '@nestjs/common';
import { MailerService } from '@nestjs-modules/mailer';
import { ConfigService } from '@nestjs/config';

export interface BookingConfirmationData {
  customerName: string;
  serviceName: string;
  date?: string;
  time?: string;
  question?: string;
  amount: number;
}

@Injectable()
export class EmailService {
  constructor(
    private readonly mailerService: MailerService,
    private readonly configService: ConfigService,
  ) {}

  async sendBookingConfirmation(
    to: string,
    data: BookingConfirmationData,
  ): Promise<void> {
    const isAppointment = !!data.date;
    const subject = isAppointment
      ? `Confirmation de votre rendez-vous - ${data.serviceName}`
      : `Confirmation de votre commande - ${data.serviceName}`;

    await this.mailerService.sendMail({
      to,
      subject,
      template: 'booking-confirmation',
      context: {
        ...data,
        isAppointment,
        formattedAmount: (data.amount / 100).toFixed(2),
        year: new Date().getFullYear(),
      },
    });
  }
}

Notez plusieurs bonnes pratiques :

  • Typage fort via une interface BookingConfirmationData qui documente le contrat.
  • Calcul du contexte côté service (formatage du montant, année courante) plutôt que dans le template.
  • Pas de chemin de fichier : on passe seulement template: 'booking-confirmation', le module trouve le bon .hbs.

Notification à l'administrateur

On peut aussi envoyer une notification interne en récupérant l'adresse admin depuis la configuration :

async sendBookingNotificationToAdmin(
  data: BookingConfirmationData & { customerEmail: string },
): Promise<void> {
  const adminEmail = this.configService.get<string>('email.user');
  if (!adminEmail) return;

  await this.mailerService.sendMail({
    to: adminEmail,
    subject: `Nouvelle réservation - ${data.serviceName}`,
    template: 'booking-admin-notification',
    context: {
      ...data,
      formattedAmount: (data.amount / 100).toFixed(2),
      year: new Date().getFullYear(),
    },
  });
}

Réponse manuelle à un message de contact

Le service peut également envoyer une réponse personnalisée à un utilisateur ayant rempli un formulaire de contact :

async sendContactReply(
  to: string,
  name: string,
  replyText: string,
): Promise<void> {
  await this.mailerService.sendMail({
    to,
    subject: 'Réponse à votre message - Le Murmure des Cartes',
    template: 'contact-reply',
    context: {
      name,
      reply: replyText,
      year: new Date().getFullYear(),
    },
  });
}

Envoyer un email simple sans template

Pour des cas simples (debug, alertes), il est possible d'envoyer du HTML brut sans passer par un template :

await this.mailerService.sendMail({
  to: 'user@example.com',
  subject: 'Test',
  html: '<p>Bonjour, ceci est un test.</p>',
  text: 'Bonjour, ceci est un test.',
});

Gestion des erreurs d'envoi

Un envoi SMTP peut échouer (panne du serveur, identifiants invalides, quota dépassé). Il est essentiel de ne pas laisser cette erreur faire planter une requête HTTP critique, comme la création d'une commande. Une bonne pratique consiste à logger l'erreur et continuer :

import { Injectable, Logger } from '@nestjs/common';

@Injectable()
export class EmailService {
  private readonly logger = new Logger(EmailService.name);

  async sendBookingConfirmation(to: string, data: BookingConfirmationData) {
    try {
      await this.mailerService.sendMail({ /* ... */ });
    } catch (error) {
      this.logger.error(
        `Échec d'envoi de l'email de confirmation à ${to}`,
        error instanceof Error ? error.stack : error,
      );
      // Selon le contexte : relancer, mettre en file d'attente, ou ignorer
    }
  }
}

Pour les applications à fort volume, déléguez l'envoi à une file de messages (Bull, RabbitMQ) avec retry automatique. Cela découple la logique métier de l'infrastructure email et améliore la résilience.

Mode développement : Mailtrap et Ethereal

En développement, vous ne voulez pas envoyer de vrais emails. Deux solutions :

  • Mailtrap.io : crée une boîte mail virtuelle qui capture tous les emails. Pratique pour tester le rendu HTML.
  • Ethereal Email : génère un compte SMTP jetable et fournit une URL de prévisualisation pour chaque email envoyé.

Configuration Mailtrap typique :

EMAIL_HOST=sandbox.smtp.mailtrap.io
EMAIL_PORT=2525
EMAIL_SECURE=false
EMAIL_USER=xxxxxxxxxx
EMAIL_PASSWORD=xxxxxxxxxx

En production, basculez sur un service transactionnel sérieux comme SendGrid, Mailgun, Amazon SES ou Postmark. Ils garantissent une bien meilleure délivrabilité que Gmail.

Utiliser le service depuis un autre module

Importez EmailModule dans le module qui en a besoin (par exemple BookingsModule) et injectez EmailService dans votre service :

@Module({
  imports: [EmailModule],
  // ...
})
export class BookingsModule {}

@Injectable()
export class BookingsService {
  constructor(private readonly emailService: EmailService) {}

  async createBooking(dto: CreateBookingDto) {
    // ... logique de création ...
    await this.emailService.sendBookingConfirmation(dto.email, {
      customerName: dto.name,
      serviceName: dto.service,
      amount: dto.amount,
    });
  }
}

Conclusion

Vous disposez désormais d'un module d'emailing complet, modulaire et prêt pour la production. Nous avons vu comment configurer MailerModule de façon asynchrone, structurer un EmailService avec plusieurs types d'emails (confirmation, notification, rappel, réponse), créer des templates Handlebars dynamiques et gérer correctement les erreurs SMTP.

Pour aller plus loin, vous pourriez explorer : la mise en place d'une file d'attente avec BullMQ pour les envois asynchrones, l'ajout de pièces jointes via l'option attachments de Nodemailer, l'écriture de tests unitaires en mockant MailerService, ou encore l'intégration d'un service comme SendGrid via son transport dédié. La documentation officielle de @nestjs-modules/mailer et de Nodemailer regorge d'options avancées à découvrir.

Commentaires

Soyez le premier à laisser un commentaire !

Laisser un commentaire

Les champs obligatoires sont indiqués avec *