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

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
fromstring—Default sender used when a message doesn't set its own from.
transportType<MailTransport>NodemailerTransportTransport class that delivers the rendered mail (resolved from DI).
transportInstanceMailTransport—A pre-built transport instance — use for transports you construct yourself (e.g. SesTransport or a failover wrapper). Takes precedence over transport.
rendererType<MailRenderer>DefaultMailRendererRenderer that produces the HTML + text bodies.
smtpSMTPOptions | string{}SMTP options, or a connection URL (smtp://user:pass@host:587), forwarded to the default nodemailer transport.
resolveTransport(tenant: string) => MailTransport—Per-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 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:

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. You can also pass a connection URL, which is the usual single-env-var form, for example for an internal relay on a private network:

MailChannelModule.forRoot({ from: 'no-reply@corp.internal', smtp: process.env.SMTP_URL }); // smtp://user:pass@relay.internal:25

Log and null transports

Two built-in transports cover environments that shouldn't really send mail:

  • LogMailTransport writes each message (recipient, subject, text body) to the Nest logger. It's the "log" driver for local development, so you can click an invite link straight from the console. Never use it in production, because links end up in your logs.
  • NullMailTransport drops every message and logs the drop at debug level. Use it where outgoing mail is deliberately off, such as CI or an air-gapped install with no relay configured.
MailChannelModule.forRoot({
  from: 'no-reply@example.com',
  ...(process.env.SMTP_URL
    ? { smtp: process.env.SMTP_URL }
    : { transport: process.env.NODE_ENV === 'production' ? NullMailTransport : LogMailTransport }),
});

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).

AWS SES

The mail package ships a first-class AWS SES v2 transport, SesTransport. Unlike SES "Simple" content (subject/html/text only), it builds a full MIME message with nodemailer's MailComposer and sends it as Content.Raw — so attachments are supported. It's a ready-made MailTransport you construct with a configured SES client and pass via transportInstance:

pnpm add @dudousxd/nestjs-notifications-mail @aws-sdk/client-sesv2
app.module.ts
import { SESv2Client } from '@aws-sdk/client-sesv2';
import { MailChannelModule, SesTransport } from '@dudousxd/nestjs-notifications-mail';

MailChannelModule.forRoot({
  from: 'no-reply@example.com',
  transportInstance: new SesTransport({
    client: new SESv2Client({ region: 'us-east-1' }),
    from: 'no-reply@example.com', // fallback sender when a message sets no `from`
  }),
});

SesTransportOptions is { client: SesV2Client; from?: string }. client is anything with a send(command) method — in practice @aws-sdk/client-sesv2's SESv2Client, but the transport only depends on the structural SesV2Client interface, so a test double or a custom-configured client works too. The @aws-sdk/client-sesv2 package is an optional peer imported lazily (only when a mail actually sends), so it isn't resolved at compile time and stays out of your bundle until you use this transport.

Because SesTransport sends raw MIME, you can attach files to a MailMessage and they ride through to SES intact — SES's Simple content type can't do that. Need the raw bytes yourself (to sign, log, or hand to another SDK call)? composeRawEmail(payload, defaultFrom?) is exported too — it returns the composed MIME Buffer for a rendered MailTransportPayload.

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