Docs / NestJS

Send email in NestJS over SMTP

NestJS has no mail module in core, but @nestjs-modules/mailer wraps Nodemailer in the dependency injection model you already use and adds template rendering. This guide configures it against Postwing, renders Handlebars templates, and moves sending onto a queue.

You can get them on the token management page. For security reasons, a token is shown only once — at the moment it is created.

SMTP connection settings

SettingValue
SMTP hostsmtp.postwing.app
Port587
EncryptionSTARTTLS (the connection is upgraded to TLS before login)
UsernameThe login of an SMTP token for your domain
PasswordThe password of that token — shown once, when the token is created
Every mode is also available on a high port: 8465 (SSL/TLS), 8587 (STARTTLS) and 8025 (plain). Many hosting providers and clouds block outbound 25, 465 and 587 — if the connection times out, switch to the matching high port.

Install the packages

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

Configure the MailerModule

Use forRootAsync so credentials come from ConfigService rather than being read at module-definition time, when the configuration is not loaded yet:

src/mail/mail.module.ts
// src/mail/mail.module.ts
import { Module } from "@nestjs/common";
import { ConfigModule, ConfigService } from "@nestjs/config";
import { MailerModule } from "@nestjs-modules/mailer";
import { HandlebarsAdapter } from "@nestjs-modules/mailer/dist/adapters/handlebars.adapter";
import { join } from "path";
import { MailService } from "./mail.service";

@Module({
  imports: [
    MailerModule.forRootAsync({
      imports: [ConfigModule],
      inject: [ConfigService],
      useFactory: (config: ConfigService) => ({
        transport: {
          host: "smtp.postwing.app",
          port: 587,
          secure: false,        // STARTTLS on 587
          requireTLS: true,
          auth: {
            user: config.getOrThrow("SMTP_USER"),
            pass: config.getOrThrow("SMTP_PASS"),   // "pass", not "password"
          },
        },
        defaults: {
          from: '"Acme" <noreply@your-domain.com>',
        },
        template: {
          dir: join(__dirname, "templates"),
          adapter: new HandlebarsAdapter(),
          options: { strict: true },
        },
      }),
    }),
  ],
  providers: [MailService],
  exports: [MailService],
})
export class MailModule {}

Write an injectable mail service

Keep the sending behind your own service. Controllers then depend on your domain method, not on the mailer's API, which makes it trivial to stub in tests:

src/mail/mail.service.ts
// src/mail/mail.service.ts
import { Injectable, Logger } from "@nestjs/common";
import { MailerService } from "@nestjs-modules/mailer";
import { User } from "../users/user.entity";

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

  constructor(private readonly mailer: MailerService) {}

  async sendConfirmation(user: User, token: string): Promise<void> {
    try {
      await this.mailer.sendMail({
        to: user.email,
        subject: "Confirm your email address",
        template: "./confirmation",     // templates/confirmation.hbs
        context: {
          name: user.name,
          url: `https://your-domain.com/confirm?token=${token}`,
        },
      });
    } catch (error) {
      this.logger.error(`Confirmation email to ${user.email} failed`, error);
      throw error;
    }
  }
}
src/mail/templates/confirmation.hbs
<!-- src/mail/templates/confirmation.hbs -->
<h1>Welcome, {{ name }}</h1>
<p>Confirm your address to finish signing up:</p>
<p><a href="{{ url }}">Confirm my email</a></p>
The Nest compiler emits only JavaScript, so .hbs files never reach dist/ and sending fails at runtime with ENOENT — typically only in production, where nobody is running from src/.
nest-cli.json
// nest-cli.json — .hbs files are not compiled, so copy them to dist
{
  "compilerOptions": {
    "assets": [{ "include": "mail/templates/**/*", "outDir": "dist" }],
    "watchAssets": true
  }
}

Queue the send

Awaiting SMTP inside a request handler ties your response time to the mail server and turns a transient failure into a 500. Enqueue instead:

javascript
// Sending inside a request ties the response to SMTP latency.
// Push it onto a BullMQ queue instead.
@Injectable()
export class UsersService {
  constructor(@InjectQueue("mail") private readonly mailQueue: Queue) {}

  async register(dto: RegisterDto): Promise<User> {
    const user = await this.repo.save(dto);
    await this.mailQueue.add(
      "confirmation",
      { userId: user.id },
      { attempts: 3, backoff: { type: "exponential", delay: 2000 } },
    );
    return user;
  }
}

@Processor("mail")
export class MailProcessor extends WorkerHost {
  constructor(private readonly mail: MailService) { super(); }

  async process(job: Job<{ userId: string }>): Promise<void> {
    const user = await this.users.findOneOrFail(job.data.userId);
    await this.mail.sendConfirmation(user, this.tokens.for(user));
  }
}

Troubleshooting

ErrorCause and fix
ENOENT on a .hbs file Templates not copied to dist. Add the assets entry to nest-cli.json.
Credentials undefined at startupforRoot used instead of forRootAsync, or ConfigModule not imported.
EAUTH — 535pass written as password, or wrong token credentials.
ETIMEDOUT in production Outbound port blocked. Use 8587 or 8465.
Nest can't resolve dependencies of MailServiceMailModule not imported where the service is injected, or not exported from it.
Queued jobs never run No worker process running, or Redis is unreachable.

Frequently asked questions

Why does NestJS throw 'ENOENT: no such file or directory' for my .hbs template?

The Nest compiler only emits .js files, so templates stay in src/ and are missing from dist/. Add an assets entry to nest-cli.json that copies mail/templates into the build output, and set watchAssets so they refresh in development.

Should I use MailerModule.forRoot or forRootAsync?

Use forRootAsync whenever the credentials come from ConfigService or any other injectable, which is almost always. forRoot evaluates its object at module-definition time, before configuration is loaded, so environment variables read there are frequently undefined.

Do I need @nestjs-modules/mailer, or can I use Nodemailer directly?

Nodemailer alone works fine — wrap a transporter in a provider and inject it. The module adds template rendering, per-request defaults and a tidier testing surface. If you do not need templates, the direct approach has one less dependency.

How do I mock the mailer in NestJS tests?

Override MailerService in the testing module with a stub whose sendMail is a jest.fn(). Because MailService depends on the injected MailerService rather than on Nodemailer directly, nothing touches the network and you can assert on the arguments.

How do I stop email sending from slowing down requests in NestJS?

Put the send behind a queue — BullMQ with @nestjs/bullmq is the usual choice. The controller enqueues a job and returns; a processor delivers it with automatic retries and exponential backoff, so a brief SMTP outage no longer surfaces as a 500.

Why does authentication fail with 535 even though the credentials look right?

Check for auth.password instead of auth.pass in the transport config — Nodemailer ignores the unknown key and sends an empty password. Otherwise confirm the username is the full SMTP token login for your domain.

Next steps