Send email notifications. A fluent MailMessage builder, an HTML + text renderer, and a swappable transport — nodemailer SMTP out of the box.
The mail channel turns a notification into an email. You shape the message with a fluent builder, a renderer produces the HTML and plain-text bodies, and a transport delivers it. The defaults — a semantic HTML renderer and a nodemailer SMTP transport — work out of the box, and every piece is swappable.
Install
pnpm add @dudousxd/nestjs-notifications-mailnpm install @dudousxd/nestjs-notifications-mailRegister the channel
import { MailChannelModule } from '@dudousxd/nestjs-notifications-mail';
@Module({
imports: [
MailChannelModule.forRoot({
from: 'billing@example.com',
smtp: { host: 'smtp.example.com', port: 587, auth: { user: '…', pass: '…' } },
}),
],
})
export class AppModule {}MailChannelModule.forRoot() takes:
| Option | Type | Default | Description |
|---|---|---|---|
from | string | — | Default sender used when a message doesn't set its own from. |
transport | Type<MailTransport> | NodemailerTransport | Transport class that delivers the rendered mail. |
renderer | Type<MailRenderer> | DefaultMailRenderer | Renderer that produces the HTML + text bodies. |
smtp | SMTPOptions | {} | SMTP options forwarded to the default nodemailer transport. |
resolveTransport | (tenant: string) => MailTransport | — | Per-tenant transport resolver. See Per-tenant transport. |
global | boolean | true | Register globally so the channel is discoverable app-wide. |
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.
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 the module's from). |
.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 channel sends with that address as to, and uses the message's from (falling back to the
module's from) as the sender.
Transports
The default transport, NodemailerTransport, wraps nodemailer's SMTP transport and is configured
through the smtp option:
MailChannelModule.forRoot({
from: 'no-reply@example.com',
smtp: { host: 'smtp.example.com', port: 587, secure: false, auth: { user, pass } },
});SMTPOptions requires host and port, with optional secure and auth; any extra keys pass
straight through to nodemailer.
To use a provider's HTTP API instead of SMTP, implement MailTransport and pass it as transport:
import { Injectable } from '@nestjs/common';
import type { MailTransport, MailTransportPayload } from '@dudousxd/nestjs-notifications-mail';
@Injectable()
export class ResendTransport implements MailTransport {
async send(payload: MailTransportPayload): Promise<void> {
// payload: { to, from?, subject, html, text }
await resend.emails.send({ to: payload.to, from: payload.from, subject: payload.subject, html: payload.html });
}
}
MailChannelModule.forRoot({ from: 'no-reply@example.com', transport: ResendTransport });The transport class is registered in DI, so it can inject its own dependencies (config, an HTTP client, your provider SDK).
Multi-provider failover
resilientTransport (from @dudousxd/nestjs-notifications-resilience)
wraps an ordered list of transports and tries each until one succeeds — so when SES is down, the next
provider (say Resend) takes over. It adds a per-provider circuit breaker (a known-dead provider is
skipped instead of retried every send) and a per-attempt timeout. Pass it as a pre-built instance
via transportInstance:
pnpm add @dudousxd/nestjs-notifications-resilience @dudousxd/nestjs-resilienceimport { resilientTransport } from '@dudousxd/nestjs-notifications-resilience';
MailChannelModule.forRoot({
from: 'no-reply@example.com',
transportInstance: resilientTransport(
[
{ id: 'ses', transport: sesTransport },
{ id: 'resend', transport: resendTransport },
],
{
keyPrefix: 'mail',
timeoutMs: 10_000,
breaker: { threshold: 5, cooldownMs: 30_000 },
onFailover: (id, error) => logger.warn(`mail provider ${id} failed over: ${error}`),
},
),
});The last error is rethrown only if every provider fails. Breaker state is per-process by default;
pass a distributed @dudousxd/nestjs-resilience-store-*
adapter as store to share it fleet-wide.
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({ from: 'no-reply@example.com', renderer: ReactEmailRenderer });
// in the notification:
toMail() {
return new MailMessage().subject('Welcome').react(<WelcomeEmail name="Ada" />);
}import { MjmlMailRenderer } from '@dudousxd/nestjs-notifications-mail';
MailChannelModule.forRoot({ from: 'no-reply@example.com', 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({ from: 'no-reply@example.com', renderer: HandlebarsRenderer });MailMessage exposes read-only getters (subjectLine, greetingText, lines, actionText,
actionUrl, salutationText, fromAddress) 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({
from: 'no-reply@example.com',
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 SMTP/provider. Pass resolveTransport —
when a send is scoped to a tenant (via forTenant(id) or a
@Tenant() property), the channel uses the transport you return instead of the default:
MailChannelModule.forRoot({
from: 'no-reply@example.com',
resolveTransport: (tenant) => tenantTransports.get(tenant) ?? defaultTransport,
});The resolver receives the tenant id and returns a MailTransport. Sends with no tenant scope keep
using the default transport. See Multi-tenancy for the full picture.
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 adapters.