Aviary
Channels

Mail

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-mail
npm install @dudousxd/nestjs-notifications-mail

Register the channel

app.module.ts
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:

OptionTypeDefaultDescription
fromstringDefault sender used when a message doesn't set its own from.
transportType<MailTransport>NodemailerTransportTransport class that delivers the rendered mail.
rendererType<MailRenderer>DefaultMailRendererRenderer that produces the HTML + text bodies.
smtpSMTPOptions{}SMTP options forwarded to the default nodemailer transport.
resolveTransport(tenant: string) => MailTransportPer-tenant transport resolver. See Per-tenant transport.
globalbooleantrueRegister 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:

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 objecttoMail({ 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:

MethodEffect
.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:

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 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:

resend.transport.ts
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-resilience
import { 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:

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({ from: 'no-reply@example.com', 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({ 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 MailRendererrender 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({ 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():

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

MailChannelModule.forRoot({
  from: 'no-reply@example.com',
  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 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:

app.module.ts
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.

On this page