Email Module for NestJS with Bull Queue and the Nest Mailer

Kevin Firko
Published:

This guide covers creating a mailer module for your NestJS app that enables you to queue emails via a service that uses @nestjs/bull and redis, which are then handled by a processor that uses the nest-modules/mailer package to send email.

NestJS is an opinionated NodeJS framework for back-end apps and web services that works on top of your choice of ExpressJS or Fastify.

Redis is a popular in-memory key-value database that will serve as the back-bone of our queue. Tasks to send emails will be added to the queue, and the NestJS processor will consume tasks from the queue.

The nest-modules/mailer package is implemented with the popular nodemailer library. Email templates are supported via handlebars.

I wrote this guide because I couldn’t find any NestJS examples that use a queue to send emails. A queue is important to prevent your app from getting bogged down when handling labour-intensive tasks such as sending mail, processing multimedia files, or crunching data.

For simplicity’s sake, the implementation covered by this guide sends emails in the same thread as they are queued. The processor will handle queued tasks when the app is idle.

An enhanced implementation could involve a separate “worker” (ideally running on a different server) that takes care of processing the queue. This way, your api is free to quickly respond to client requests, and the burden of email processing is handled by a dedicated resource.

Redis for Development

Perhaps the easiest way to get a redis instance rolling for development purposes is with Docker. Assuming you have Docker installed on your machine, you can run the following command:

docker run -p 6379:6379 --name redisqueue -d redis

Port 6379 is the default redis port. Make sure you don’t already have a conflicting service running on port 6379!

To later stop the redis instance, run the command:

docker stop redisqueue

Installing Dependencies

Install the following project dependencies.

I use yarn but you can easily change the commands to reflect npm or another favourite package manager:

yarn add @nestjs/bull bull

yarn add --dev @types/bull

yarn add @nestjs-modules/mailer

yarn add handlebars

Module Creation

Create a new module named mail in your project.

You can use the nestjs cli to scaffold the module running the following command in the root of your project folder: nest g module mail.

In your module folder, create a new sub-folder called templates/.

Handlebars templates in your src/mail/templates folder won’t automatically be copied over into the project build folder. You can solve this by adding compilerOptions to your project’s nest-cli.json file and specifying an assets folder. An example nest-cli.json file follows:

{
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "assets": [
      "mail/templates/**/*"
    ]
  }
}

In the root mail.module.ts file, configure the MailerModule and BullModule in the module’s imports.

The following example assumes the config package is being used as a configuration tool. You can substitute your own configuration package, or simply hardcode values to get things working:

import { Module } from '@nestjs/common'
import { MailService } from './mail.service'
import { MailProcessor } from './mail.processor'
import { MailerModule } from '@nestjs-modules/mailer'
import { HandlebarsAdapter } from '@nestjs-modules/mailer/dist/adapters/handlebars.adapter'
import { BullModule } from '@nestjs/bull'
import * as config from 'config'

@Module({
  imports: [
    MailerModule.forRootAsync({
      useFactory: () => ({
        transport: {
          host: config.get('mail.host'),
          port: config.get('mail.port'),
          secure: config.get<boolean>('mail.secure'),
          // tls: { ciphers: 'SSLv3', }, // gmail
          auth: {
            user: config.get('mail.user'),
            pass: config.get('mail.pass'),
          },
        },
        defaults: {
          from: config.get('mail.from'),
        },
        template: {
          dir: __dirname + '/templates',
          adapter: new HandlebarsAdapter(),
          options: {
            strict: true,
          },
        },
      }),
    }),
    BullModule.registerQueueAsync({
      name: config.get('mail.queue.name'),
      useFactory: () => ({
        redis: {
          host: config.get('mail.queue.host'),
          port: config.get('mail.queue.port'),
        },
      }),
    }),
  ],
  controllers: [],
  providers: [
    MailService,
    MailProcessor,
  ],
  exports: [
    MailService,
  ],
})
export class MailModule {}

Creating the Mail Service

Use NestJS’ @InjectQueue() decorator to inject the mailQueue (of type Queue, as imported from the ‘bull’ package):

  constructor(
    @InjectQueue(config.get('mail.queue.name'))
    private mailQueue: Queue,
  ) {}

You can now implement a function that adds a task to the queue. In the example below, the task is named ‘confirmation’ and passed a payload containing the user and confirmation code:

  /** Send email confirmation link to new user account. */
  async sendConfirmationEmail(user: User, code: string): Promise<boolean> {
    try {
      await this.mailQueue.add('confirmation', {
        user,
        code,
      })
      return true
    } catch (error) {
      // this.logger.error(`Error queueing confirmation email to user ${user.email}`)
      return false
    }
  }

Creating the Mail Processor

In order for queued tasks to be handled, we need to define a processor.

Create mail.processor.ts in your mail module folder.

Be sure to import the MailerService provided by the @nestjs-modules/mailer package:

import { MailerService } from '@nestjs-modules/mailer'

Use the @Processor() decorator to identify your class as a processor for the mail queue, and add the MailerService to the constructor to inject it via NestJS’ dependency injection:

@Processor(config.get('mail.queue.name'))
export class MailProcessor {
  private readonly logger = new Logger(this.constructor.name)

  constructor(
    private readonly mailerService: MailerService,
  ) {}

  // ...
}

The following example implements a number of decorated functions using the decorators @OnQueueActive(), @OnQueueCompleted(), and @OnQueueFailed() to provide better visibility and logging into how the processor is working.

To implement a function that handles the ‘confirmation’ task, decorate it with the @Process() decorator and pass it the task name: @Process('confirmation'). Note how the payload is received and can be used in the task.

@Processor(config.get('mail.queue.name'))
export class MailProcessor {
  private readonly logger = new Logger(this.constructor.name)

  constructor(
    private readonly mailerService: MailerService,
  ) {}

  @OnQueueActive()
  onActive(job: Job) {
    this.logger.debug(`Processing job ${job.id} of type ${job.name}. Data: ${JSON.stringify(job.data)}`)
  }

  @OnQueueCompleted()
  onComplete(job: Job, result: any) {
    this.logger.debug(`Completed job ${job.id} of type ${job.name}. Result: ${JSON.stringify(result)}`)
  }

  @OnQueueFailed()
  onError(job: Job<any>, error: any) {
    this.logger.error(`Failed job ${job.id} of type ${job.name}: ${error.message}`, error.stack)
  }

  @Process('confirmation')
  async sendWelcomeEmail(job: Job<{ user: User, code: string }>): Promise<any> {
    this.logger.log(`Sending confirmation email to '${job.data.user.email}'`)

    const url = `${config.get('server.origin')}/auth/${job.data.code}/confirm`

    if (config.get<boolean>('mail.live')) {
      return 'SENT MOCK CONFIRMATION EMAIL'
    }

    try {
      const result = await this.mailerService.sendMail({
        template: 'confirmation',
        context: {
          ...plainToClass(User, job.data.user),
          url: url,
        },
        subject: `Welcome to ${config.get('app.name')}! Please Confirm Your Email Address`,
        to: job.data.user.email,
      })
      return result

    } catch (error) {
      this.logger.error(`Failed to send confirmation email to '${job.data.user.email}'`, error.stack)
      throw error
    }
  }
}

Creating the Handlebars Template

Create the file confirmation.hbs in your mail module’s templates/ subfolder:

<p>Hello {{ firstName }}</p>
<p>Please click the link below to confirm your email address:</p>
<p><a href="{{ url }}" target="_blank">Confirm Email</a></p>

Note how the context is being used to provide data to the email body.

Using the Mail Service in Another Module

Suppose another module needs to send email, such as an auth module that needs to send an email confirmation link to a new user.

Open the module definition file, e.g. src/auth/auth.module.ts then add the MailModule we created to its imports list in the @Module decorator:

// ...
import { MailModule } from '../mail/mail.module'
// ...

@Module({
  imports: [
    // ...
    MailModule
    // ...
  ]
// ...
})

You can then use the MailModule provided service MailService in your controllers and services via Dependency Injection.

Import the MailService (import { MailService } from '../mail/mail.service') and in your constructor, add the definition private mailService: MailService to inject it.

You can than call methods defined in your service, such as:

this.mailService.sendConfirmationEmail(user, '1234')

The mail service will add the email task to the queue, and the mail processor will “pick up” and complete the task when your app is idle.

Wrap-Up

Don’t forget to turn off your redis queue when you are done development!