Heximon Logo
Recipes

Send an Email

Inject the abstract EmailTransport DI token, compose an EmailMessage with Mustache placeholders, and send it — with a capture transport for dev/tests and ResendTransport as the one-line production swap.

Send a welcome email from a service, without hard-coding a provider into the code that composes it. You inject the abstract EmailTransport token; the module decides whether that resolves to an in-memory capture transport (tests, local dev) or Resend in production.

The full picture — SMS, push, templating, suppression, orchestration across channels — is the Notifications page; this recipe is the shortest path to one send.

1. Bind a transport in a module

EmailTransport is an abstract class, so it's a DI token — the compiler wires whichever concrete class satisfies it. Provide a capture transport (records sends in memory, never calls a real provider) so the recipe runs with no API key and no network:

src/mail/capture-email-transport.ts
import { uuid } from "@heximon/runtime";
import type { TransportResult } from "@heximon/notifications";
import { type EmailMessage, EmailTransport } from "@heximon/notifications/email";

/** Records every email in memory instead of delivering it. */
export class CaptureEmailTransport extends EmailTransport {
  private readonly captured: { id: string; subject: string; content: string }[] = [];

  public sent(): readonly { id: string; subject: string; content: string }[] {
    return [...this.captured];
  }

  protected override async transport(message: EmailMessage): Promise<TransportResult> {
    const id = uuid.v7();
    this.captured.push({
      id,
      subject: message.subject,
      content: message.getContent(), // renders the Mustache placeholders
    });
    return { messageId: id, timestamp: new Date() };
  }
}
src/mail/mail.module.ts
import { Module } from "@heximon/runtime";
import { CaptureEmailTransport } from "./capture-email-transport";

export class MailModule extends Module({
  providers: [CaptureEmailTransport],
  exports: [CaptureEmailTransport],
}) {}

Because CaptureEmailTransport extends EmailTransport, it satisfies the abstract token — any consumer that injects EmailTransport gets it, and any consumer that injects the concrete CaptureEmailTransport can also read its capture store back.

2. Compose and send the message

EmailMessage takes one props object — an HTML content with Mustache placeholders and the values to fill them. Inject EmailTransport and call send(); it never throws, so a provider outage can't take down the request that triggered it.

src/mail/welcome-email.service.ts
import type { DeliveryResult } from "@heximon/notifications";
import { EmailMessage, EmailTransport } from "@heximon/notifications/email";

export class WelcomeEmailService {
  public constructor(private readonly mail: EmailTransport) {}

  public async sendTo(user: { name: string; email: string }): Promise<DeliveryResult> {
    const message = new EmailMessage({
      from: { email: "hello@example.com" },
      to: { name: user.name, email: user.email },
      subject: "Welcome, {{ name }}!",
      content: "<p>Thanks for joining, {{ name }}.</p>",
      placeholders: { name: user.name },
    });
    return this.mail.send(message);
  }
}

List the service alongside the transport so it can inject it:

src/mail/mail.module.ts
import { Module } from "@heximon/runtime";
import { CaptureEmailTransport } from "./capture-email-transport";
import { WelcomeEmailService } from "./welcome-email.service";

export class MailModule extends Module({
  providers: [CaptureEmailTransport, WelcomeEmailService],
  exports: [CaptureEmailTransport, WelcomeEmailService],
}) {}

3. Read the result

send() returns a DeliveryResult instead of throwing, so the caller decides what a failure means — log it, retry it, or surface it — rather than the transport deciding for you:

import { DeliveryStatus } from "@heximon/notifications";

const result = await welcomeEmail.sendTo({ name: "Ada", email: "ada@example.com" });

if (result.status === DeliveryStatus.Accepted) {
  console.log("sent:", result.messageId);
} else {
  console.error("failed:", result.reason, "retryable:", result.retryable);
}

Go to production

Swap the binding, not the code. resend is an optional peer — install it, then bind ResendTransport to the EmailTransport token via useFactory (it needs an API key, a literal value DI can't supply):

pnpm add @heximon/notifications resend
src/mail/mail.module.ts
import { Module } from "@heximon/runtime";
import { EmailTransport } from "@heximon/notifications/email";
import { ResendTransport } from "@heximon/notifications/email/resend";
import { WelcomeEmailService } from "./welcome-email.service";

export class MailModule extends Module({
  providers: [
    { provide: EmailTransport, useFactory: () => new ResendTransport({ apiKey: process.env.RESEND_API_KEY! }) },
    WelcomeEmailService,
  ],
  exports: [EmailTransport, WelcomeEmailService],
}) {}

WelcomeEmailService is unchanged — it injects the abstract EmailTransport, never a concrete provider, so this one binding is the entire production cutover. unemail is the other optional-peer transport if you'd rather not depend on Resend.

One EmailTransport per app graph. The token resolves to exactly one subclass — binding two fails the build with an ambiguous-binding error. Compose per environment (dev binds the capture transport, production binds Resend) rather than binding both at once.

See also

  • Notifications — the full surface: SMS, push, per-channel templating, suppression on bounces, and the NotificationDispatcher for fanning one logical notification across channels.
  • Background Jobs — send from an IntegrationEventHandler instead of inline, so the triggering request never waits on provider latency.
  • the gap's notifications example — email + SMS + push over capture transports, a SuppressionService-backed webhook controller, and an in-process test asserting exactly what each channel sent.
Copyright © 2026