Heximon Logo
Capabilities

Notifications

Multi-channel notifications behind one Message / MessageTransport contract — email (Resend, unemail), SMS (Twilio), push (OneSignal, FCM, Web Push), TemplateRenderer, SuppressionService, and the NotificationDispatcher.

Send an email, a text, or a push notification through a transport you can swap without touching the code that composes it. Every channel narrows the same two shapes — a templated Message and the MessageTransport that delivers it — so a capture transport in a test and Resend or Twilio in production run the exact same sending code.

Start with email: you build an EmailMessage (an HTML body with Mustache placeholders) and hand it to an injected EmailTransport. For the shortest path — one transport, one send — see the send-an-email recipe; this page covers the full surface: composing, sending, custom transports, the other channels, templating, and orchestration.

Install

resend and unemail are optional peers — install only the one whose transport you bind. It loads on first send, so the email layer never pulls in a provider you don't use.

pnpm add @heximon/notifications

There's no compiler plugin to register — the abstract transports are plain DI tokens a module binds.

Compose an email

EmailMessage is built from a single props object. The HTML content carries Mustache placeholders, placeholders supplies their values, and getContent() renders them.

import { EmailMessage } from "@heximon/notifications/email";

const message = new EmailMessage({
  from: { name: "Heximon Example", email: "noreply@example.com" },
  to: { name: "Jane Doe", email: "jane@example.com" },
  subject: "Welcome aboard!",
  content: "<p>Hi {{ name }}, your account id is <code>{{ userId }}</code>.</p>",
  placeholders: { name: "Jane", userId: "01HF…" },
});

message.getContent();           // rendered HTML, Mustache applied
await message.getTextContent(); // plain-text alternative derived from the HTML

A placeholder value can be static or a thunkplaceholders: { token: () => mintToken() } — evaluated only when the body renders, so an expensive or single-use value is computed lazily.

PropTypeNotes
fromEmailAddress{ name?, email }
toEmailAddress | EmailAddress[]Recipient(s)
subjectstringSubject line (not templated)
contentstringHTML body, Mustache-enabled
placeholdersPlaceholdersStatic values or thunks
cc / bccEmailAddress | EmailAddress[]Optional
replyToEmailAddressOptional Reply-To
headersRecord<string, string>Custom headers
attachmentsEmailAttachment[]string content is Base64; a Buffer is encoded for you

Send and read the result

send() never throws — it drives the lifecycle and returns a DeliveryResult, recording a failure on the message rather than raising it, so a missed email can't take down the request that triggered it.

The result carries the outcome explicitly: a status (Accepted or Failed), the delivery channel, the provider messageId on acceptance, and — on failure — whether it is retryable (so a queue consumer knows to re-try a rate limit but dead-letter an invalid address):

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

const result = await transport.send(message);

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

The message tracks its own state, readable any time with getStatus():

StatusMeaning
DraftInitial state after construction
PendingDelivery has started
SentThe provider accepted it — getId() and getSentDate() are now set
FailedDelivery failed — getErrors() is populated
import { MessageStatus } from "@heximon/notifications";

message.getStatus(); // MessageStatus.Draft | .Pending | .Sent | .Failed
message.isSent();    // boolean

Bind a transport in a module

EmailTransport is an abstract class, and an abstract class is a DI token. Bind the real provider through useFactoryResendTransport takes a config object (the API key), a literal value DI can't supply, so you build it yourself.

mail.module.ts
import { Module } from "@heximon/runtime";
import { EmailTransport } from "@heximon/notifications/email";
import { ResendTransport } from "@heximon/notifications/email/resend";

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

Consumers inject the abstract token and stay oblivious to the provider — switch it by changing this one binding:

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);
  }
}

One EmailTransport impl per app graph. The token resolves to exactly one subclass, so binding two fails the build with an ambiguous-binding error — compose per environment, or inject a concrete subclass where you want a specific one.

Write a custom transport

To target a provider with no adapter — or to capture mail in tests — extend EmailTransport and implement the protected transport(). The base class owns every lifecycle transition, so your subclass is just the raw provider call: return a TransportResult on success, throw TransportError on failure.

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() };
  }
}

extends EmailTransport makes it the satisfier; listing the concrete class lets a controller inject it directly to read the capture store back:

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

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

send() catches a TransportError, records a clean message, and returns a Failed DeliveryResult — set { retryable: true, retryAfterSeconds } on the error to surface a transient failure to a queue consumer; any other thrown value becomes a non-retryable Failed result under a generic "unexpected error" reason.

Send from an event handler

The most common shape: build the same kind of EmailMessage in reaction to an event instead of inline in a request, so the triggering HTTP request returns immediately instead of blocking on provider latency.

welcome-email.handler.ts
import type { IntegrationEventHandler } from "@heximon/integration";
import { EmailMessage } from "@heximon/notifications/email";
import { CaptureEmailTransport } from "./capture-email-transport";

export class WelcomeEmailHandler implements IntegrationEventHandler<"user.signed-up", SignedUpPayload> {
  public constructor(private readonly transport: CaptureEmailTransport) {}

  public async handle(payload: SignedUpPayload): Promise<void> {
    const message = new EmailMessage({
      from: { name: "Heximon Example", email: "noreply@example.com" },
      to: { name: payload.name, email: payload.email },
      subject: "Welcome aboard!",
      content: "<p>Hi {{ name }},</p><p>Your account id is <code>{{ userId }}</code>.</p>",
      placeholders: { name: payload.name, userId: payload.userId },
    });

    await this.transport.send(message);
  }
}

Declare the handler under its module's integration.eventHandlers namespace, not in providers — that's what dispatches it when the queue drains.

Beyond email — SMS & push

EmailMessage and EmailTransport specialize the channel-agnostic Message / MessageTransport — and they are not the only channel. SMS ships the same way at @heximon/notifications/sms: an SmsMessage (E.164 addressing, GSM-7/UCS-2 segment estimation) and the abstract SmsTransport token, with an edge-safe TwilioSmsTransport (./sms/twilio) adapter that calls the Twilio REST API with fetch + form encoding — no SDK.

import { SmsTransport } from "@heximon/notifications/sms";
import { SmsMessage } from "@heximon/notifications/sms";

export class OtpService {
  public constructor(private readonly sms: SmsTransport) {}

  public async sendCode(phone: string, code: string): Promise<boolean> {
    const result = await this.sms.send(
      new SmsMessage({ to: phone, content: "Your code is {{code}}.", placeholders: { code } }),
    );
    return result.status === "accepted";
  }
}

Same DeliveryResult, same swap-by-binding story — bind TwilioSmsTransport to the SmsTransport token via useFactory.

Push ships at @heximon/notifications/push: a PushMessage + the abstract PushTransport token + an app-owned PushDeviceRegistry (the framework ships no concrete device store — you implement getTokensForRecipient / register / markInvalidated over your own database or KV).

Three edge-safe adapters ship with it — OneSignalPushTransport (./push/onesignal, one fetch per send, relays to APNs/FCM/web), FcmPushTransport (./push/fcm, FCM HTTP v1, RS256 JWT signed via WebCrypto), and a first-party WebPushTransport (./push/web-push, VAPID + aes128gcm encryption, entirely on WebCrypto — no SDK, no Node).

A provider that reports a dead token surfaces it on DeliveryResult.tokenInvalidated, so a handler can purge it from the registry:

order-shipped-push.handler.ts
import type { NotificationHandler } from "@heximon/events";
import { DeliveryStatus } from "@heximon/notifications";
import { PushDeviceRegistry, PushMessage, PushTransport } from "@heximon/notifications/push";

export class OrderShippedPushHandler implements NotificationHandler<OrderShipped> {
  public constructor(
    private readonly push: PushTransport,
    private readonly devices: PushDeviceRegistry,
  ) {}

  public async handle(event: OrderShipped): Promise<void> {
    for (const device of await this.devices.getTokensForRecipient(event.customerId)) {
      const result = await this.push.send(
        new PushMessage({
          to: { token: device.token, webPushKeys: device.webPushKeys },
          title: "Your order shipped",
          content: "Order {{orderId}} is on its way.",
          placeholders: { orderId: event.orderId },
          url: "/orders/{{orderId}}",
        }),
      );

      if (result.status === DeliveryStatus.Failed && result.tokenInvalidated === true) {
        await this.devices.markInvalidated(event.customerId, device.token);
      }
    }
  }
}

For any other channel, subclass Message / MessageTransport with your own getChannel() + transport() — you inherit the same Mustache rendering, lifecycle, and DeliveryResult.

Render one template across channels

TemplateRenderer (@heximon/notifications/template) renders one logical template per channel from a static TemplateMap (templateId → locale → channel templates). The default MustacheTemplateRenderer interpolates with Mustache; locale resolution is per-channel (fr-CA → fr → default), and the email subject / push title are templated too:

import { MustacheTemplateRenderer, type TemplateMap } from "@heximon/notifications/template";

const templates: TemplateMap = {
  "order.shipped": {
    default: {
      email: { subject: "Order {{orderId}} shipped", html: "<p>Hi {{name}}, it's on its way.</p>" },
      sms: { body: "Order {{orderId}} shipped." },
      push: { title: "Order shipped", body: "{{orderId}} is on its way", url: "/orders/{{orderId}}" },
    },
  },
};

const renderer = new MustacheTemplateRenderer(templates);
await renderer.render("order.shipped", "email", { orderId: "A1", name: "Ada" });
// → { channel: "email", subject: "Order A1 shipped", html: "<p>Hi Ada, it's on its way.</p>" }

Orchestrating across channels

Sending one message through one transport is the low level. Above it, @heximon/notifications/orchestration turns a single channel-agnostic Notification (a templateId + payload + recipient + category + channels) into per-channel sends: NotificationDispatcher resolves the recipient's eligible channels (honoring per-recipient ChannelPreferencesbypassed for the System category, so 2FA always sends).

It then renders each through the TemplateRenderer, builds the channel Message, and sends it through the bound transport — Fanout sends every eligible channel, Fallback tries them in order until one is accepted (push fans out over the recipient's devices).

Bind DefaultNotificationDispatcher via useFactory — its parameters are injected by type, so listing the channel transports, the app's RecipientResolver / PushDeviceRegistry ports, and an optional SuppressionService is the whole wiring:

src/notifications/notifications.module.ts
import { Module } from "@heximon/runtime";
import { Storage } from "@heximon/kv";
import { MemoryStorage } from "@heximon/kv/memory";
import { EmailTransport } from "@heximon/notifications/email";
import {
  DefaultNotificationDispatcher,
  NotificationDispatcher,
  RecipientResolver,
} from "@heximon/notifications/orchestration";
import { PushDeviceRegistry, PushTransport } from "@heximon/notifications/push";
import { SmsTransport } from "@heximon/notifications/sms";
import { SuppressionService } from "@heximon/notifications/suppression";
import { MustacheTemplateRenderer, TemplateRenderer } from "@heximon/notifications/template";
import { AppDeviceRegistry } from "./app-device-registry";
import { AppRecipientResolver } from "./app-recipient-resolver";
import { CaptureEmailTransport, CapturePushTransport, CaptureSmsTransport } from "./capture-transports";
import { templates } from "./templates";

export class NotificationsModule extends Module({
  providers: [
    CaptureEmailTransport,
    CaptureSmsTransport,
    CapturePushTransport,
    AppRecipientResolver,
    AppDeviceRegistry,
    { provide: TemplateRenderer, useFactory: () => new MustacheTemplateRenderer(templates) },
    { provide: Storage, useFactory: () => new MemoryStorage() },
    // SuppressionService is a framework class, but its constructor (the Storage port) is recovered
    // from the shipped types, so a bare provider wires it — no useFactory needed.
    SuppressionService,
    {
      provide: NotificationDispatcher,
      useFactory: (
        renderer: TemplateRenderer,
        recipients: RecipientResolver,
        email: EmailTransport,
        sms: SmsTransport,
        push: PushTransport,
        devices: PushDeviceRegistry,
        suppression: SuppressionService,
      ) =>
        new DefaultNotificationDispatcher({
          renderer,
          recipients,
          suppression,
          email: { transport: email, from: { name: "Heximon", email: "noreply@example.com" } },
          sms: { transport: sms },
          push: { transport: push, devices },
        }),
    },
  ],
}) {}

Dispatch a logical notification — the dispatcher renders and fans it out, returning a DispatchResult:

import { uuid } from "@heximon/runtime";
import { NotificationChannel } from "@heximon/notifications";
import { NotificationCategory, NotificationStrategy } from "@heximon/notifications/orchestration";

const result = await dispatcher.dispatch({
  id: uuid.v7(),
  type: "order.shipped",
  recipientId: user.id,
  category: NotificationCategory.Transactional,
  channels: [NotificationChannel.Push, NotificationChannel.Email],
  strategy: NotificationStrategy.Fallback,
  templateId: "order.shipped",
  payload: { orderId: order.id, name: user.name },
});

result.allAccepted; // true when every attempted channel was accepted

Suppress bounces and complaints

SuppressionService (@heximon/notifications/suppression, over the @heximon/kv Storage port) keeps a bounce/complaint list under suppress:<channel>:<recipient> keys.

A delivery-status webhook controller verifies and normalizes the provider callback into a DeliveryEvent, then calls applyDeliveryEvent — suppressing on a hard bounce or complaint, ignoring a soft failure (which is transient and retried, not suppressed). The dispatcher, when given a SuppressionService, skips suppressed email/SMS recipients before sending:

import { DeliveryEventKind, SuppressionService } from "@heximon/notifications/suppression";

export class ResendWebhookService {
  public constructor(private readonly suppression: SuppressionService) {}

  // Called by an app HTTP controller after it has verified the webhook signature.
  public async onEvent(event: { type: string; email: string }): Promise<void> {
    if (event.type === "email.bounced" || event.type === "email.complained") {
      await this.suppression.applyDeliveryEvent({
        channel: "email",
        recipient: event.email,
        kind: event.type === "email.bounced" ? DeliveryEventKind.Bounced : DeliveryEventKind.Complained,
      });
    }
  }
}

Reliability at scale

Three concerns beyond a single send, mostly reusing existing Heximon tiers:

  • Idempotency. send(message, { idempotencyKey }) threads a provider idempotency key (Resend's Idempotency-Key header); NotificationDispatcher passes the Notification.id automatically (and a per-device key for push, so a retry de-dupes the same device but distinct devices never collide).
  • Suppression — covered above.
  • Transactional outbox. Don't send inside the request. Raise a domain event, let the DDD outbox emit it atomically with the DB commit, have an IntegrationEventHandler enqueue the Notification, and a queue handler call the dispatcher — at-least-once delivery with the queue's retry/backoff (DeliveryResult.retryable decides re-queue versus dead-letter).

Every transport here is fetch/WebCrypto-based — the one node: reference in the package is type-only — so the whole notifications stack, dispatcher included, runs on an edge deploy the same as on Node.

See also

  • Send an email — the shortest path: one transport, one send.
  • Events — when to send mail synchronously versus from an event handler, and the three event tiers.
  • Queue — hand long-lived, retryable delivery to a background queue so a slow provider never stalls a request.
  • Idempotency — the queue-tier interceptor that dedups a redelivered notification.
  • Modules — how useFactory bindings and the abstract-token-as-DI pattern fit the DI graph.
  • 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.
  • the ladder's L08 — Queue — a signup emits an integration event, an event handler composes a Mustache welcome email and sends it through a capture transport, and a test asserts exactly what was sent.
Copyright © 2026