Sending Emails from NestJS
- nestjs
- nodejs
- typescript
- backend
- redis
- bullmq
- api
In Handling File Uploads in NestJS we dealt with getting bytes from a user into storage without exposing the app to arbitrary files it didn't ask for. Email has a similar shape: a feature that sounds like a one-line API call and turns into an entire subsystem once you look at what production actually demands of it.
This is part of the NestJS Production Guide, the series that walks through the pieces a real API needs beyond the tutorial version. If you're new here, the architecture overview at the start of the series covers the module boundaries and layering this article assumes.
Every SaaS sends email whether it wants to or not: password resets, invoices, invitation links, security alerts. None of that is optional, and none of it tolerates being slow, silently dropped, or blocked on a third-party API sitting inside a request handler. This article covers how to wire email into a NestJS app in a way that survives a provider outage, a malformed template, and the eventual need to send more than a handful of messages a day.
Wrapping the provider instead of calling it directly
The first decision is where the provider's SDK lives in your module graph. It should live in exactly one place: a dedicated MailModule with a MailService that every other module depends on. Nothing outside that module should import an SES client, a Resend client, or nodemailer directly.
This is a practical decision, not abstraction for its own sake. You will change providers at least once, usually when a cheaper option becomes viable at scale or a deliverability problem forces a switch. When that happens, you want it to be a change in one file, not a grep across the codebase.
// mail/mail.service.ts
import { Injectable, Logger } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import * as nodemailer from 'nodemailer';
export interface SendMailOptions {
to: string;
subject: string;
html: string;
text?: string;
}
@Injectable()
export class MailService {
private readonly logger = new Logger(MailService.name);
private readonly transporter: nodemailer.Transporter;
private readonly fromAddress: string;
constructor(private readonly config: ConfigService) {
this.fromAddress = this.config.getOrThrow<string>('MAIL_FROM_ADDRESS');
this.transporter = nodemailer.createTransport({
host: this.config.getOrThrow<string>('SMTP_HOST'),
port: this.config.get<number>('SMTP_PORT', 587),
secure: false,
auth: {
user: this.config.getOrThrow<string>('SMTP_USER'),
pass: this.config.getOrThrow<string>('SMTP_PASSWORD'),
},
});
}
async send(options: SendMailOptions): Promise<void> {
try {
await this.transporter.sendMail({
from: this.fromAddress,
to: options.to,
subject: options.subject,
html: options.html,
text: options.text,
});
} catch (error) {
this.logger.error(`Failed to send mail to ${options.to}`, error);
throw error;
}
}
}
// mail/mail.module.ts
import { Module } from '@nestjs/common';
import { MailService } from './mail.service';
@Module({
providers: [MailService],
exports: [MailService],
})
export class MailModule {}
Using nodemailer against SMTP works with SES, Postmark, Resend, and most other providers with only a config change, which is exactly the point. If a provider gives you meaningfully better tooling through its own API (bounce webhooks, suppression lists), swap the transport internals inside MailService without touching a single caller.
Templates that don't live as string concatenation
Building HTML email by concatenating strings in a service method works for exactly one email before it becomes unmaintainable. Every provider-specific quirk (inline CSS, no flexbox, table-based layouts for Outlook) belongs in a template layer, not scattered through business logic.
A common approach is a small templating step with Handlebars or a similar engine, rendering to a string that MailService.send accepts as html. If the team already writes React for the frontend, React Email is worth considering too. It renders JSX to email-safe HTML and lets you preview templates the same way you preview components, which keeps template review inside the normal frontend review flow instead of a separate design tool.
// mail/templates/password-reset.template.ts
import * as Handlebars from 'handlebars';
const source = `
<p>Hi {{name}},</p>
<p>Click the link below to reset your password. It expires in one hour.</p>
<p><a href="{{resetUrl}}">Reset your password</a></p>
`;
const compiled = Handlebars.compile(source);
export function renderPasswordResetEmail(params: { name: string; resetUrl: string }): string {
return compiled(params);
}
Keep the compiled template cached rather than recompiling per call. This one is small enough that it barely matters, but the same pattern with a dozen templates and real traffic makes a measurable difference.
Sending through a queue, not the request thread
This is the single most important decision in this whole article: an email API call has no business sitting inside an HTTP request handler that a user is waiting on. The provider's normal latency is fine. The failure mode isn't. When a provider has a slow moment, or times out, or rate-limits you, the request handler that triggered the email is now blocked on a system that has nothing to do with whether the user's actual request (sign up, request a password reset, invite a teammate) succeeded.
Route email through a queue. BullMQ, backed by Redis, is the natural choice if the rest of the stack already leans on Redis for caching or sessions. The request handler enqueues a job and returns immediately. A separate worker process does the actual sending, with retries that never touch the request thread.
// mail/mail.processor.ts
import { Processor, WorkerHost } from '@nestjs/bullmq';
import { Job } from 'bullmq';
import { MailService } from './mail.service';
import { renderPasswordResetEmail } from './templates/password-reset.template';
interface PasswordResetJobData {
to: string;
name: string;
resetUrl: string;
}
@Processor('mail')
export class MailProcessor extends WorkerHost {
constructor(private readonly mailService: MailService) {
super();
}
async process(job: Job<PasswordResetJobData>): Promise<void> {
if (job.name === 'password-reset') {
const html = renderPasswordResetEmail(job.data);
await this.mailService.send({
to: job.data.to,
subject: 'Reset your password',
html,
});
}
}
}
// auth/auth.service.ts
import { Injectable } from '@nestjs/common';
import { InjectQueue } from '@nestjs/bullmq';
import { Queue } from 'bullmq';
@Injectable()
export class AuthService {
constructor(@InjectQueue('mail') private readonly mailQueue: Queue) {}
async requestPasswordReset(email: string, name: string, resetUrl: string): Promise<void> {
await this.mailQueue.add(
'password-reset',
{ to: email, name, resetUrl },
{ attempts: 5, backoff: { type: 'exponential', delay: 5000 } },
);
}
}
The attempts and backoff options are doing real work here. A transient failure, the provider returning a 500, a brief network blip, gets retried automatically with increasing delay instead of silently failing on the first try. That's the difference between "the email eventually arrived" and "the user never got their password reset link and doesn't know why."
For a genuinely early-stage product with low volume, sending synchronously with a retry wrapper is a defensible shortcut. Don't build the queue before you need it. But the moment email volume is more than trivial, or the request handler triggering it is user-facing and latency-sensitive, move it to a queue. Retrofitting this after a production incident is more painful than building it in from the start.
Handling bounces, complaints, and deliverability
An accepted API call from your email provider tells you almost nothing about whether the message was actually delivered. Real deliverability work happens outside your application code entirely: SPF, DKIM, and DMARC records on the sending domain, and ideally a dedicated subdomain so a deliverability problem never touches your primary domain's reputation.
Inside the app, the part that matters is handling the provider's bounce and complaint webhooks. If a user's address hard-bounces, you want to stop sending to it, both because continuing to send hurts your sender reputation with that provider and because it's often a sign the account itself has a stale email on file.
// mail/mail-events.controller.ts
import { Body, Controller, Post } from '@nestjs/common';
import { MailEventsService } from './mail-events.service';
interface BounceWebhookPayload {
email: string;
type: 'bounce' | 'complaint';
}
@Controller('webhooks/mail')
export class MailEventsController {
constructor(private readonly mailEvents: MailEventsService) {}
@Post()
async handle(@Body() payload: BounceWebhookPayload): Promise<void> {
await this.mailEvents.recordEvent(payload.email, payload.type);
}
}
MailEventsService here would mark the address as unsendable in your database, checked before MailService.send is called anywhere in the app. This is a small amount of code, and it's the difference between a mailing infrastructure that stays healthy and one that quietly degrades until a support ticket says "I never got the invoice."
Testing without actually sending mail
Tests that hit a real email provider are slow, flaky, and occasionally forget to be idempotent about it, which is how a test suite starts sending real password reset emails. Mock MailService in unit tests, and for integration tests, run a local SMTP catcher like MailHog or Mailpit in Docker, so you can verify a real send happened and inspect the rendered HTML without any external dependency.
// mail/mail.service.spec.ts
import { Test } from '@nestjs/testing';
import { MailService } from './mail.service';
describe('MailService', () => {
it('throws when the transporter rejects the send', async () => {
const moduleRef = await Test.createTestingModule({
providers: [MailService],
}).compile();
const mailService = moduleRef.get(MailService);
jest
.spyOn(mailService as any, 'transporter', 'get')
.mockReturnValue({ sendMail: jest.fn().mockRejectedValue(new Error('SMTP timeout')) });
await expect(
mailService.send({ to: 'user@example.com', subject: 'Test', html: '<p>test</p>' }),
).rejects.toThrow('SMTP timeout');
});
});
What matters here is that the service surfaces the failure rather than swallowing it, not the mock setup itself. A MailService that logs an error and returns as if nothing happened is worse than one that throws, because a swallowed failure means the caller (and the queue's retry logic) never finds out the send didn't happen.
Key takeaways
- Wrap the email provider behind a single `MailService` so switching providers later is a config change, not a codebase-wide search and replace.
- Keep templates out of business logic. Handlebars, MJML, or React Email all beat string concatenation for anything beyond a one-line message.
- Send through a queue backed by Redis once volume or request latency justifies it. Synchronous sending with retries is fine for an early-stage product, but don't wait for an incident to move off it.
- Configure `attempts` and `backoff` on queued jobs so transient provider failures don't become silently dropped emails.
- Handle bounce and complaint webhooks and stop sending to addresses that hard-bounce; this protects sender reputation as much as it protects the user experience.
- Test the failure path, not just the happy path. A `MailService` that swallows errors defeats the retry logic sitting on top of it.
Frequently asked questions
Should I use nodemailer or a provider's own SDK in NestJS?
Nodemailer over SMTP works with nearly every provider and keeps your `MailService` provider-agnostic. Reach for a provider's own SDK only if you need features SMTP doesn't expose, like detailed bounce webhooks or suppression list management, and even then keep that logic behind the same service boundary.
Do I need BullMQ to send email from NestJS, or can I send it directly?
Not from day one. Sending directly with a retry wrapper is a reasonable starting point at low volume. Move to a queue once a slow provider response could block a user-facing request, or once volume makes synchronous retries unreliable.
How do I prevent test suites from sending real emails?
Mock `MailService` in unit tests. For integration tests that need to verify a real SMTP send, run MailHog or Mailpit locally in Docker as a catch-all SMTP server, so nothing leaves your machine.
What's the difference between SPF, DKIM, and DMARC, and do I need all three?
SPF lists which servers can send mail for your domain. DKIM cryptographically signs outgoing mail. DMARC tells receiving servers what to do when either check fails and gives you abuse reports. All three are DNS records, not application code, and skipping any of them tends to show up later as mail landing in spam once you have enough volume to trigger filters.
Why separate transactional email from marketing email?
Transactional emails, password resets, invoices, security alerts, are generally exempt from the consent rules that apply to marketing email because the user's own action triggered them. Mixing promotional content into a transactional template, or routing both through the same sending domain, blurs that distinction and can drag your transactional deliverability down with it.
How should I handle an email that fails after all retry attempts?
Log it with enough context to investigate, and record the failure against the user or job it belonged to so a support workflow or a scheduled job can retry it manually. A job that exhausts its retries and disappears silently is the same problem as no retry logic at all, just delayed.
Further reading
Explore more on

About the author
I'm Aman Kumar Singh, a software engineer in Noida, India building scalable full-stack products with React, Next.js, Node.js, NestJS, PostgreSQL, Redis, and AWS. I write about backend engineering, distributed systems, and system design.