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/mailnpm install @dudousxd/nestjs-notifications-mail @nestjs/mailRegister the channel
Configure MailModule as the NestJS mail docs describe,
then add the channel:
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:
| Option | Type | Default | Description |
|---|---|---|---|
renderer | Type<MailRenderer> | DefaultMailRenderer | Renderer that produces the HTML + text bodies of a MailMessage. |
rendererInstance | MailRenderer | — | A pre-built renderer instance; takes precedence over renderer. |
retry | number | false | MailRetryOptions | the MailModule's | @nestjs/mail retry for the channel's sends. Set false when an outer queue already retries deliveries (see Retries). |
global | boolean | true | Register 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:
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:
| Method | Effect |
|---|---|
.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:
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 | |
|---|---|---|
allowHttp | false | Only https: URLs. |
allowPrivateHosts | false | Refuses 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. |
timeoutMs | 30000 | The whole download, headers and body. |
maxBytes | 26214400 (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:
| Renderer | Drive it with | Needs |
|---|---|---|
DefaultMailRenderer | the MailMessage builder | — |
MarkdownMailRenderer | message.markdown('# Hi') | marked |
ReactEmailRenderer | message.react(<Email/>) | react + @react-email/render |
MjmlMailRenderer | message.mjml('<mjml>…') | mjml |
import { ReactEmailRenderer } from '@dudousxd/nestjs-notifications-mail';
MailChannelModule.forRoot({ renderer: ReactEmailRenderer });
// in the notification:
toMail() {
return new MailMessage().subject('Welcome').react(<WelcomeEmail name="Ada" />);
}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:
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():
import { MailChannelModule, MarkdownMailRenderer } from '@dudousxd/nestjs-notifications-mail';
MailChannelModule.forRoot({ renderer: MarkdownMailRenderer });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.
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');Channels
A channel is a transport — mail, database, Slack, and the real-time SSE / WebSocket channels. Import a channel's module and it registers itself; the notification's to<Channel>() method shapes the payload.
Database
Persist notifications so you can show an in-app feed. A NotificationStore interface, a bundled in-memory store, and TypeORM / MikroORM / Prisma / Drizzle adapters.