nestjs-notifications
Channels

Mail

Send email notifications. A fluent MailMessage builder and renderers (Markdown, React Email, MJML) on top of @nestjs/mail, which owns transports, retries and events.

The mail channel turns a notification into an email. Sending is @nestjs/mail's: its MailModule owns the transports (SMTP, Resend, Postmark, SendGrid, SES, log, file, in-memory), the default sender, retries, MailEvents and test helpers. This page documents only what the channel adds: the MailMessage builder, renderers, mail classes per notification, per-tenant routing and URL attachments.

Install

pnpm add @dudousxd/nestjs-notifications-mail @nestjs/mail
npm install @dudousxd/nestjs-notifications-mail @nestjs/mail

Register the channel

Configure MailModule as the NestJS mail docs describe, then add the channel:

app.module.ts
import { MailModule, SmtpTransport } from '@nestjs/mail';
import { MailChannelModule } from '@dudousxd/nestjs-notifications-mail';

@Module({
  imports: [
    MailModule.forRoot({
      transport: new SmtpTransport({ host: 'smtp.example.com', port: 587, auth: { user: '…', pass: '…' } }),
      from: 'Billing <billing@example.com>',
    }),
    MailChannelModule.forRoot(),
  ],
})
export class AppModule {}

MailChannelModule.forRoot() takes:

OptionTypeDefaultDescription
rendererType<MailRenderer>DefaultMailRendererRenderer that produces the HTML + text bodies of a MailMessage.
rendererInstanceMailRenderer—A pre-built renderer instance; takes precedence over renderer.
retrynumber | false | MailRetryOptionsthe MailModule's@nestjs/mail retry for the channel's sends. Set false when an outer queue already retries deliveries (see Retries).
globalbooleantrueRegister globally so the channel is discoverable app-wide.

The channel returns @nestjs/mail's MailSendResult as its response, so delivery tracking records the provider's message id (the SES MessageId an SES bounce refers to).

The notification side

Annotate the payload method with the @Mail() handle — that declares the mail channel and defines its payload. The method returns a MailMessage:

invoice-paid.notification.ts
import { type Notifiable, Notification } from '@dudousxd/nestjs-notifications-core';
import { Mail, MailMessage } from '@dudousxd/nestjs-notifications-mail';

@Notification()
export class InvoicePaid {
  constructor(private invoiceId: string) {}

  @Mail()
  toMail({ notifiable }: ChannelContext): MailMessage {
    return new MailMessage()
      .subject(`Invoice ${this.invoiceId} paid`)
      .greeting('Thanks for your payment!')
      .line('We received your payment. No further action is needed.')
      .action('View invoice', `https://app.example.com/invoices/${this.invoiceId}`)
      .salutation('— The billing team');
  }
}

The Mail handle doubles as a via() token (via() { return [Mail]; }) for the cases where you need explicit routing. For compile-time checks on toMail()'s shape, implement MailNotification alongside the decorator.

toMail() receives a context object — toMail({ notifiable, localization }) — so you can translate the subject and lines per recipient. Destructure only what you need; use localization.t(key, params) to render in the recipient's language. See Localization. The resolved locale is also recorded on the sent message.

Mail classes

toMail() can return a @nestjs/mail mail class with its data instead of a MailMessage. The channel sends it with mailer.send(mail, { to, data, locale }), in the recipient's locale:

@Mail()
toMail() {
  return { mail: OrderShippedMail, data: this.order };
}

The object may also carry from, replyTo, headers and attachments overrides.

MailMessage builder

MailMessage is a fluent builder. Each method returns this, so calls chain:

MethodEffect
.from(addr)Override the sender for this message (otherwise MailModule's from).
.replyTo(addr)Set the Reply-To address.
.header(name, value)Add a custom header (List-Unsubscribe, X-Entity-Ref-ID).
.attach(attachment)Attach a file: @nestjs/mail's attachment (content or a local path), or { filename, url }.
.subject(s)Set the subject line.
.greeting(s)Set the leading greeting (rendered as a heading).
.line(s)Append a body paragraph. Call it more than once for multiple paragraphs.
.action(text, url)Add a single call-to-action button.
.salutation(s)Set the closing line.

Recipient address

The recipient comes from the notifiable's routeNotificationFor('mail') — return the email address there:

user.ts
export class User implements Notifiable {
  constructor(public id: number, public email: string) {}

  routeNotificationFor(channel: string) {
    if (channel === 'mail') return this.email;
    return undefined;
  }
}

The route may also be { name, address } or a list of addresses. The sender is the message's from, else MailModule's.

Attachments from a URL

@nestjs/mail reads an attachment path from the local disk only. For a file at a URL, give url: the channel downloads it before sending. Prefer URLs your app chooses over ones built from user input.

new MailMessage().subject('Your report').attach({ filename: 'report.pdf', url: report.downloadUrl });

The download is guarded by default, and a refused one fails the delivery:

Option (MailChannelModule.forRoot({ attachments }))Default
allowHttpfalseOnly https: URLs.
allowPrivateHostsfalseRefuses hosts that are or resolve to loopback, private, link-local (cloud metadata), CGNAT or other non-public addresses. The resolved address is checked when the connection is made, so DNS rebinding can't slip one in.
timeoutMs30000The whole download, headers and body.
maxBytes26214400 (25 MiB)Checked against Content-Length and while streaming.

Redirects are never followed. For an internal file server, set attachments: { allowPrivateHosts: true } (and allowHttp: true if it has no TLS).

Retries

@nestjs/mail retries a failed send (3 attempts by default). When notifications also go through a retrying queue (BullMQ, or the outbox with errorPolicy: 'failFast'), attempts multiply; give the channel retry: false so only the queue retries. A delivery from the outbox passes its message id as idempotencyKey, so a redelivered mail keeps its Message-ID.

Multi-provider failover

resilientTransport from @dudousxd/nestjs-notifications-resilience wraps an ordered list of @nestjs/mail transports, each behind a @nestjs/resilience circuit breaker and timeout, and fails over to the next one. Pass it as MailModule's transport:

import { ResendTransport, SesTransport, MailModule } from '@nestjs/mail';
import { resilientTransport } from '@dudousxd/nestjs-notifications-resilience';

MailModule.forRoot({
  from: 'no-reply@example.com',
  transport: resilientTransport(
    [
      { id: 'ses', transport: new SesTransport({ region: 'us-east-1' }) },
      { id: 'resend', transport: new ResendTransport({ apiKey }) },
    ],
    { keyPrefix: 'mail', timeout: '10s', breaker: { failureRateThreshold: 50, openDuration: '30s' } },
  ),
});

Renderers

DefaultMailRenderer turns a MailMessage into semantic HTML — the greeting becomes an <h1>, each line a <p>, the action a styled <a> button — plus a plain-text fallback. Three more renderers ship in the box; pick one with the renderer option:

RendererDrive it withNeeds
DefaultMailRendererthe MailMessage builder—
MarkdownMailRenderermessage.markdown('# Hi')marked
ReactEmailRenderermessage.react(<Email/>)react + @react-email/render
MjmlMailRenderermessage.mjml('<mjml>…')mjml
React Email
import { ReactEmailRenderer } from '@dudousxd/nestjs-notifications-mail';

MailChannelModule.forRoot({ renderer: ReactEmailRenderer });

// in the notification:
toMail() {
  return new MailMessage().subject('Welcome').react(<WelcomeEmail name="Ada" />);
}
MJML
import { MjmlMailRenderer } from '@dudousxd/nestjs-notifications-mail';

MailChannelModule.forRoot({ renderer: MjmlMailRenderer });

toMail() {
  return new MailMessage().subject('Welcome').mjml('<mjml><mj-body>…</mj-body></mjml>');
}

Each built-in renderer falls back to DefaultMailRenderer when the message has no body of its kind, so you can mix React-Email and plain builder notifications under one renderer.

To render with something else (Handlebars, your own templating), implement MailRenderer — render may be sync or async:

custom.renderer.ts
import { Injectable } from '@nestjs/common';
import type { MailMessage, MailRenderer } from '@dudousxd/nestjs-notifications-mail';

@Injectable()
export class HandlebarsRenderer implements MailRenderer {
  async render(message: MailMessage): Promise<{ html: string; text: string }> {
    return { html: await renderTemplate(message), text: message.lines.join('\n\n') };
  }
}

MailChannelModule.forRoot({ renderer: HandlebarsRenderer });

MailMessage exposes read-only getters (subjectLine, greetingText, lines, actionText, actionUrl, salutationText, fromAddress, replyToAddress, headerValues, attachments) so a custom renderer can read everything the builder collected.

Markdown

When you'd rather write the body as Markdown than chain .line() calls, swap in the MarkdownMailRenderer and set the body with .markdown():

app.module.ts
import { MailChannelModule, MarkdownMailRenderer } from '@dudousxd/nestjs-notifications-mail';

MailChannelModule.forRoot({ renderer: MarkdownMailRenderer });
invoice-paid.notification.ts
import { Mail, MailMessage } from '@dudousxd/nestjs-notifications-mail';

@Mail()
toMail(): MailMessage {
  return new MailMessage()
    .subject(`Invoice ${this.invoiceId} paid`)
    .markdown(`# Thanks for your payment!\n\nInvoice **${this.invoiceId}** is settled.`);
}

The renderer parses the Markdown to HTML and uses the raw Markdown as the plain-text fallback. A message with no .markdown() body falls back to the default renderer's output, so you can mix Markdown and builder-style notifications under one renderer.

MarkdownMailRenderer parses with marked, an optional peer dependency — install it (pnpm add marked) only if you use this renderer.

Per-tenant transport

In a multi-tenant app each tenant can send through its own provider. Give MailModule a TenantRoutingMailTransport: it picks the transport per message from the tenant the notification is delivered for (forTenant(id) or a @Tenant() property). The tenant travels in an AsyncLocalStorage scope around the send, never as a header on the message.

app.module.ts
import { MailModule, SmtpTransport } from '@nestjs/mail';
import { TenantRoutingMailTransport } from '@dudousxd/nestjs-notifications-mail';

MailModule.forRoot({
  from: 'no-reply@example.com',
  transport: new TenantRoutingMailTransport(
    (tenant) => tenantTransports.get(tenant), // undefined → the fallback
    new SmtpTransport({ host: 'smtp.example.com' }),
  ),
});

Mail without a tenant (single-tenant sends, or a direct mailer.send()) goes through the fallback. To route a direct send by tenant, wrap it in runInMailScope({ tenant }, () => mailer.send(...)); currentMailScope() reads the scope (a MailEvents subscriber runs inside it too).

Testing

Use @nestjs/mail's InMemoryMailTransport and its assertions (docs):

const mailbox = new InMemoryMailTransport();
// MailModule.forRoot({ transport: mailbox }) or .overrideProvider(MailTransport).useValue(mailbox)
await notifications.send(user, new InvoicePaid('INV-1'));
mailbox.assertSent({ to: user.email }).link('/invoices/INV-1');

On this page