NestJS – Gửi Email với Nodemailer & SendGrid

 

Gửi email là tính năng không thể thiếu trong ứng dụng web: xác nhận đăng ký, reset mật khẩu, thông báo đơn hàng. Bài này hướng dẫn hai cách phổ biến: Nodemailer (SMTP linh hoạt) và SendGrid (dịch vụ email chuyên nghiệp).

1. Nodemailer — Gửi qua SMTP

Cài đặt

npm install nodemailer
npm install -D @types/nodemailer

Tạo MailService

// src/mail/mail.service.ts
import { Injectable, Logger } from '@nestjs/common';
import * as nodemailer from 'nodemailer';
import { Transporter } from 'nodemailer';

export interface MailOptions {
  to: string | string[];
  subject: string;
  html: string;
  text?: string;
  attachments?: Array<{ filename: string; path: string }>;
}

@Injectable()
export class MailService {
  private transporter: Transporter;
  private readonly logger = new Logger(MailService.name);

  constructor() {
    this.transporter = nodemailer.createTransport({
      host: process.env.SMTP_HOST,         // smtp.gmail.com
      port: Number(process.env.SMTP_PORT), // 587
      secure: false,                        // true cho port 465
      auth: {
        user: process.env.SMTP_USER,        // your@gmail.com
        pass: process.env.SMTP_PASS,        // App Password (không phải mật khẩu Gmail)
      },
    });
  }

  async sendMail(options: MailOptions): Promise<void> {
    try {
      await this.transporter.sendMail({
        from: `"${process.env.MAIL_FROM_NAME}" <${process.env.SMTP_USER}>`,
        ...options,
      });
      this.logger.log(`Email sent to ${options.to}`);
    } catch (error) {
      this.logger.error(`Failed to send email: ${error.message}`);
      throw error;
    }
  }
}

MailModule

// src/mail/mail.module.ts
import { Module, Global } from '@nestjs/common';
import { MailService } from './mail.service';

@Global()  // Global để dùng ở bất kỳ module nào mà không cần import
@Module({
  providers: [MailService],
  exports: [MailService],
})
export class MailModule {}

2. Template Email với Handlebars

npm install handlebars
// src/mail/mail.service.ts
import * as Handlebars from 'handlebars';
import * as fs from 'fs';
import * as path from 'path';

@Injectable()
export class MailService {
  // ...

  private compileTemplate(templateName: string, context: Record<string, any>): string {
    const templatePath = path.join(__dirname, 'templates', `${templateName}.hbs`);
    const templateSource = fs.readFileSync(templatePath, 'utf-8');
    const template = Handlebars.compile(templateSource);
    return template(context);
  }

  async sendWelcomeEmail(to: string, name: string): Promise<void> {
    const html = this.compileTemplate('welcome', { name, year: new Date().getFullYear() });
    await this.sendMail({
      to,
      subject: `Chào mừng ${name} đến với ShopXYZ!`,
      html,
    });
  }

  async sendPasswordReset(to: string, resetToken: string): Promise<void> {
    const resetUrl = `${process.env.FRONTEND_URL}/reset-password?token=${resetToken}`;
    const html = this.compileTemplate('password-reset', { resetUrl });
    await this.sendMail({
      to,
      subject: 'Đặt lại mật khẩu của bạn',
      html,
    });
  }

  async sendOrderConfirmation(to: string, order: any): Promise<void> {
    const html = this.compileTemplate('order-confirmation', { order });
    await this.sendMail({
      to,
      subject: `Xác nhận đơn hàng #${order.id}`,
      html,
    });
  }
}
<!-- src/mail/templates/welcome.hbs -->
<!DOCTYPE html>
<html>
<body style="font-family: Arial, sans-serif; max-width: 600px; margin: 0 auto;">
  <h2>Chào mừng, ! 🎉</h2>
  <p>Cảm ơn bạn đã đăng ký tài khoản tại ShopXYZ.</p>
  <a href="" style="
    background: #007bff;
    color: white;
    padding: 12px 24px;
    border-radius: 4px;
    text-decoration: none;
    display: inline-block;
    margin-top: 16px;
  ">Bắt đầu mua sắm</a>
  <p style="color: #666; font-size: 12px; margin-top: 32px;">
    ©  ShopXYZ. All rights reserved.
  </p>
</body>
</html>

3. SendGrid — Dịch vụ email chuyên nghiệp

SendGrid cung cấp deliverability cao, analytics, và không cần cấu hình SMTP.

npm install @sendgrid/mail
// src/mail/sendgrid.service.ts
import { Injectable, Logger } from '@nestjs/common';
import * as sgMail from '@sendgrid/mail';

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

  constructor() {
    sgMail.setApiKey(process.env.SENDGRID_API_KEY!);
  }

  async sendMail(options: {
    to: string | string[];
    subject: string;
    html: string;
    text?: string;
  }): Promise<void> {
    try {
      await sgMail.send({
        from: { email: process.env.SENDGRID_FROM_EMAIL!, name: 'ShopXYZ' },
        ...options,
      });
    } catch (error) {
      this.logger.error('SendGrid error:', error.response?.body ?? error.message);
      throw error;
    }
  }

  // Dùng Dynamic Template (thiết kế sẵn trên SendGrid dashboard)
  async sendWithTemplate(
    to: string,
    templateId: string,
    dynamicData: Record<string, any>,
  ): Promise<void> {
    await sgMail.send({
      to,
      from: process.env.SENDGRID_FROM_EMAIL!,
      templateId,
      dynamicTemplateData: dynamicData,
    });
  }
}

Dynamic Template với SendGrid

// Gửi email dùng template thiết kế sẵn trên SendGrid
await this.sendGridService.sendWithTemplate(
  user.email,
  'd-abc123xyz',  // Template ID từ SendGrid dashboard
  {
    name: user.name,
    order_id: order.id,
    items: order.items,
    total: order.total,
    tracking_url: `https://shop.example.com/track/${order.id}`,
  },
);

4. Queue Email — Không block request

Gửi email nên bất đồng bộ để không làm chậm API response:

// Dùng BullMQ (xem bài NestJS Queue BullMQ)
@Injectable()
export class UsersService {
  constructor(
    @InjectQueue('mail') private mailQueue: Queue,
  ) {}

  async register(dto: RegisterDto) {
    const user = await this.usersModel.create(dto);

    // Đẩy vào queue — không đợi gửi xong
    await this.mailQueue.add('welcome', {
      to: user.email,
      name: user.name,
    });

    return user;
  }
}

// Worker xử lý queue
@Processor('mail')
export class MailProcessor extends WorkerHost {
  constructor(private mailService: MailService) { super(); }

  async process(job: Job) {
    if (job.name === 'welcome') {
      await this.mailService.sendWelcomeEmail(job.data.to, job.data.name);
    }
  }
}

5. Retry khi gửi thất bại

async sendMailWithRetry(options: MailOptions, maxRetries = 3): Promise<void> {
  for (let attempt = 1; attempt <= maxRetries; attempt++) {
    try {
      await this.sendMail(options);
      return;
    } catch (error) {
      if (attempt === maxRetries) throw error;
      const delay = attempt * 2000; // Exponential backoff: 2s, 4s, 6s
      this.logger.warn(`Retry ${attempt}/${maxRetries} after ${delay}ms`);
      await new Promise(resolve => setTimeout(resolve, delay));
    }
  }
}

6. Biến môi trường

# Nodemailer (Gmail)
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USER=your@gmail.com
SMTP_PASS=abcd-efgh-ijkl-mnop   # Google App Password

# SendGrid
SENDGRID_API_KEY=SG.xxxxxxxxxxxx
SENDGRID_FROM_EMAIL=noreply@shopxyz.com

MAIL_FROM_NAME=ShopXYZ
FRONTEND_URL=https://shopxyz.com

Lưu ý: Gmail cần bật 2FA và tạo App Password tại myaccount.google.com → Security → App passwords.

7. Kết luận

  • Nodemailer: Linh hoạt, dùng bất kỳ SMTP nào (Gmail, Mailgun, AWS SES)
  • SendGrid: Deliverability cao, dashboard analytics, Dynamic Template mạnh
  • Handlebars template: Tách biệt logic và HTML email
  • Queue: Bắt buộc khi gửi email hàng loạt — không block API response
  • Retry: Xử lý trường hợp SMTP tạm thời lỗi

Trong production, ưu tiên dùng SendGrid hoặc AWS SES thay vì Gmail SMTP.