Sending Email with @nestjs/mail.

Kamil Mysliwiec | Trilon Consulting
Kamil Mysliwiec

Today I'm excited to announce @nestjs/mail, a new package for sending transactional email from Nest. Mails are injectable classes that render their subject and body from typed data, HTML templates escape values automatically, and the package ships with transports for SMTP, Resend, Postmark, SendGrid, and SES, plus first-class support for the outbox pattern, so an email goes out only after your database transaction commits.

In case you're not familiar with NestJS, it is a TypeScript Node.js framework that helps you build enterprise-grade efficient and scalable Node.js applications.

Let's dive right in! 🐈

Why a new mail package?

Sending an email looks trivial, until it isn't. The HTML must be safe: a product name or a customer name ends up in the markup, and unescaped, a name like <script> or a stray & breaks the layout (or worse). The plain-text version has to match the HTML. Templates need to be translated. And the hardest part: an order confirmation must go out exactly when the order is saved. Send it before the commit and the transaction rolls back, and the customer gets a confirmation for an order that doesn't exist. Send it after, and the process crashes in between, and the customer never hears from you.

@nestjs/mail takes care of all of it, and lets you focus on what the email should actually say.

Getting started

First, install the package:

$ npm i --save @nestjs/mail

Then register the module:

import { join } from 'node:path';
import { Module } from '@nestjs/common';
import { FileMailTransport, FileTemplateEngine, MailModule } from '@nestjs/mail';
@Module({
imports: [
MailModule.forRoot({
transport: new FileMailTransport({ directory: 'var/mail' }),
templates: new FileTemplateEngine({
dir: join(import.meta.dirname, 'mail/templates'),
layout: 'layout',
cache: process.env.NODE_ENV === 'production',
}),
from: 'Orders <orders@example.com>',
}),
],
})
export class AppModule {}

In development, FileMailTransport doesn't send anything. Instead, it writes every mail as an .eml file into var/mail, so you can open it in any mail client and see exactly what your customers would see. No SMTP server, no sandbox account, no accidental emails to real people. 🙃

Since templates aren't TypeScript files, remember to add them to the assets in your nest-cli.json:

{
"compilerOptions": {
"assets": ["mail/templates/**/*"],
"watchAssets": true
}
}

Mails are classes

Each type of email is an injectable class implementing the Mailable interface. It receives typed data and returns everything the email needs:

@Injectable()
export class OrderConfirmationMail implements Mailable<OrderConfirmation> {
constructor(private readonly invoicePdf: InvoicePdf) {}
render({ order, customer }: OrderConfirmation, { locale }: MailRenderContext) {
return {
subject: `Your order #${order.number}`,
template: 'order-confirmation',
context: {
customer,
items: order.items.map((item) => ({ /* ... */ })),
total: price(order.total),
orderUrl: `${SHOP_URL}/orders/${order.id}`,
},
attachments: [
{ cid: 'logo@example.com', path: LOGO },
{
filename: `invoice-${order.number}.pdf`,
content: this.invoicePdf.render(order, customer),
},
],
};
}
}

Since it's a regular provider, it can inject anything it needs (here, a service that renders the PDF invoice). Sending it is a one-liner:

await this.mailer.send(OrderConfirmationMail, {
to: { name: customer.name, address: customer.email },
data: { order, customer },
locale: customer.locale,
});

And TypeScript makes sure data matches what the mail expects.

Templates that are safe by default

The built-in FileTemplateEngine uses logic-less HTML templates with a syntax you probably already know:

SyntaxBehavior
{{ name }}Escaped value (an empty string if missing)
{{{ html }}}Unescaped HTML
{{#if condition}} ... {{/if}}Conditional block (#unless works too)
{{#each items}} ... {{/each}}Iteration, with this, @index, @first, and @last
{{> partial}}Includes a partial

Everything between double braces is escaped, so a customer named <script> is just a customer with an unusual name. You have to opt in to raw HTML explicitly, with triple braces. The plain-text version of the email is derived from the HTML automatically, so you don't have to maintain two copies of every template.

Translations are just files. Add order-confirmation.pl.html next to order-confirmation.html, and it's used whenever you send with locale: 'pl'. If you already use an i18n library, the locale is available in render() as well, so translating the subject is straightforward.

Prefer Handlebars, MJML, or something else entirely? Extend MailTemplateEngine and plug in your own engine.

Transports

Switching from files to real delivery is a configuration change, not a code change:

export function mailTransport(env: NodeJS.ProcessEnv = process.env): MailTransport {
switch (env.MAIL_TRANSPORT ?? 'file') {
case 'file':
return new FileMailTransport({ directory: env.MAIL_DIRECTORY ?? 'var/mail' });
case 'smtp':
return new SmtpTransport({ url: required(env, 'SMTP_URL'), pool: true });
case 'resend':
return new ResendTransport({ apiKey: required(env, 'RESEND_API_KEY') });
default:
throw new Error(`MAIL_TRANSPORT must be "file", "smtp" or "resend"`);
}
}

Out of the box, you get:

  • SmtpTransport - native SMTP with TLS, AUTH, connection pooling, and DKIM signing
  • ResendTransport, PostmarkTransport, SendGridTransport, and SesTransport - HTTP APIs of the most popular providers
  • FileMailTransport - writes .eml files, for development
  • InMemoryMailTransport - captures mails, for tests

Errors you can actually act on

Every failure is a MailError with a code and a permanent flag. Transient problems, such as timeouts, dropped connections, or a provider telling you to slow down, are retried automatically. Permanent ones, such as a rejected recipient or an invalid payload, fail immediately, because no amount of retrying is going to fix them.

Sending after commit, with the outbox

This is my favorite part. Instead of calling the mailer in the middle of your business logic, record the fact that an order was placed in the same transaction as the order itself:

const order = await this.db.transaction(async (tx) => {
const order = await this.insertOrder(tx, customerId, items);
await this.outbox.add(tx, { topic: 'order.placed', payload: order });
return order;
});
this.outbox.notify();

Either both the order and the message are saved, or neither is. Then a handler picks the message up and sends the email:

@OnOutboxMessage('order.placed', { consumer: 'order-confirmation-mail' })
async sendConfirmation(order: Order, { message, signal }: OutboxHandlerContext) {
try {
await this.mailer.send(OrderConfirmationMail, {
to: { name: customer.name, address: customer.email },
data: { order, customer },
idempotencyKey: message.id,
signal,
retry: false,
});
} catch (error) {
if (error instanceof MailError && error.permanent) {
throw new NonRetryableMessageError(error.message, { cause: error });
}
throw error;
}
}

A few details are worth pointing out:

  • retry: false hands retries over to the outbox, which survives restarts (an in-process retry loop doesn't).
  • idempotencyKey: message.id lets providers that support it (such as Resend) deduplicate the email, even if the handler runs twice.
  • A permanent error becomes a NonRetryableMessageError, so the message goes straight to the dead letters, instead of being retried 10 times.

‼️ Mail is sent after the commit, survives crashes, and never goes out for an order that was rolled back. That's the guarantee you actually want from transactional email. ‼️

Testing

Swap in the InMemoryMailTransport, and assert on what was sent:

const mail = mailbox.assertSent({ to: 'ada@example.com', mail: OrderConfirmationMail });
expect(mail.subject).toBe(`Your order #${order.number}`);
expect(mail.attachment(`invoice-${order.number}.pdf`).contentType).toBe('application/pdf');
expect(mail.link('/orders/').pathname).toBe(`/orders/${order.id}`);

No regular expressions over raw HTML, no parsing MIME by hand. You can look up attachments by file name and links by path.

Try it today!

$ npm i --save @nestjs/mail

The new Mail chapter in the official documentation covers everything above in depth, including i18n, custom template engines, every send option, and a production checklist (SPF, DKIM, DMARC, and friends). Give it a try, and please open an issue if anything gets in your way!

Happy coding! 🐈


Learn NestJS - Official NestJS Courses 📚

Level-up your NestJS and Node.js ecosystem skills in these incremental workshop-style courses, from the NestJS Creator himself, and help support the NestJS framework! 🐈

🚀 The NestJS Fundamentals Course is now LIVE and 25% off for a limited time!

🎉 NEW - NestJS Course Extensions now live!
#NestJS
#NodeJS

Share this Post!

📬 Trilon Newsletter

Stay up to date with all the latest Articles & News!

More from the Trilon Blog .

Kamil Mysliwiec | Trilon Consulting
Kamil Mysliwiec

Announcing @nestjs/storage

Meet @nestjs/storage, one API for local disks and S3-compatible storage in NestJS, with safe uploads, signed URLs, direct uploads, and an in-memory disk for tests.

Read More
Kamil Mysliwiec | Trilon Consulting
Kamil Mysliwiec

Distributed Locks with @nestjs/locks

Meet @nestjs/locks, distributed locks for NestJS: run scheduled jobs on one instance, prevent overlapping runs, and elect a leader, with leases and fencing tokens.

Read More
Kamil Mysliwiec | Trilon Consulting
Kamil Mysliwiec

Introducing @nestjs/resilience

Meet @nestjs/resilience, retries, timeouts, circuit breakers, bulkheads, and fallbacks for NestJS controllers, resolvers, microservices, and services.

Read More

What we do at Trilon .

At Trilon, our goal is to help elevate teams - giving them the push they need to truly succeed in today's ever-changing tech world.

Trilon - Consulting

Consulting .

Let us help take your Application to the next level - planning the next big steps, reviewing architecture, and brainstorming with the team to ensure you achieve your most ambitious goals!

Trilon - Development and Team Augmentation

Development .

Trilon can become part of your development process, making sure that you're building enterprise-grade, scalable applications with best-practices in mind, all while getting things done better and faster!

Trilon - Workshops on NestJS, Node, and other modern JavaScript topics

Workshops .

Have a Trilon team member come to YOU! Get your team up to speed with guided workshops on a huge variety of topics. Modern NodeJS (or NestJS) development, JavaScript frameworks, Reactive Programming, or anything in between! We've got you covered.

Trilon - Open-source contributors

Open-source .

We love open-source because we love giving back to the community! We help maintain & contribute to some of the largest open-source projects, and hope to always share our knowledge with the world!

Explore more

Write us a message .

Let's talk about how Trilon can help your next project get to the next level.

Rather send us an email? Write to:

hello@trilon.io
© 2019-2026 Trilon.