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.
| Setting | Value |
|---|---|
| SMTP host | smtp.postwing.app |
| Port | 587 |
| Encryption | STARTTLS (the connection is upgraded to TLS before login) |
| Username | The login of an SMTP token for your domain |
| Password | The password of that token — shown once, when the token is created |
npm install @nestjs-modules/mailer nodemailer handlebars
npm install -D @types/nodemailer 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
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 {}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
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 -->
<h1>Welcome, {{ name }}</h1>
<p>Confirm your address to finish signing up:</p>
<p><a href="{{ url }}">Confirm my email</a></p>.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 — .hbs files are not compiled, so copy them to dist
{
"compilerOptions": {
"assets": [{ "include": "mail/templates/**/*", "outDir": "dist" }],
"watchAssets": true
}
}Awaiting SMTP inside a request handler ties your response time to the mail server and turns a transient failure into a 500. Enqueue instead:
// 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));
}
}| Error | Cause and fix |
|---|---|
ENOENT on a .hbs file | Templates not copied to dist. Add the assets entry to nest-cli.json. |
Credentials undefined at startup | forRoot used instead of forRootAsync, or ConfigModule not imported. |
EAUTH — 535 | pass written as password, or wrong token credentials. |
ETIMEDOUT in production | Outbound port blocked. Use 8587 or 8465. |
Nest can't resolve dependencies of MailService | MailModule not imported where the service is injected, or not exported from it. |
| Queued jobs never run | No worker process running, or Redis is unreachable. |
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.
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.
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.
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.
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.
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.