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/mailThen 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:
| Syntax | Behavior |
|---|---|
{{ 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 signingResendTransport,PostmarkTransport,SendGridTransport, andSesTransport- HTTP APIs of the most popular providersFileMailTransport- writes.emlfiles, for developmentInMemoryMailTransport- 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: falsehands retries over to the outbox, which survives restarts (an in-process retry loop doesn't).idempotencyKey: message.idlets 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/mailThe 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 Advanced Concepts Course now LIVE!
- NestJS Advanced Bundle (Advanced Architecture and Advanced Concepts) now 22% OFF!
- NestJS Microservices now LIVE!
- NestJS Authentication / Authorization Course now LIVE!
- NestJS GraphQL Course (code-first & schema-first approaches) are now LIVE!
- NestJS Authentication / Authorization Course now LIVE!
