Notifications
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
pnpm add @heximon/notifications resend
pnpm add @heximon/notifications unemail
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 thunk — placeholders: { token: () => mintToken() } —
evaluated only when the body renders, so an expensive or single-use value is computed lazily.
| Prop | Type | Notes |
|---|---|---|
from | EmailAddress | { name?, email } |
to | EmailAddress | EmailAddress[] | Recipient(s) |
subject | string | Subject line (not templated) |
content | string | HTML body, Mustache-enabled |
placeholders | Placeholders | Static values or thunks |
cc / bcc | EmailAddress | EmailAddress[] | Optional |
replyTo | EmailAddress | Optional Reply-To |
headers | Record<string, string> | Custom headers |
attachments | EmailAttachment[] | 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():
| Status | Meaning |
|---|---|
Draft | Initial state after construction |
Pending | Delivery has started |
Sent | The provider accepted it — getId() and getSentDate() are now set |
Failed | Delivery 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 useFactory — ResendTransport takes a config object (the API key), a literal value DI can't
supply, so you build it yourself.
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:
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.
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:
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.
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:
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 ChannelPreferences — bypassed 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:
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'sIdempotency-Keyheader);NotificationDispatcherpasses theNotification.idautomatically (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
IntegrationEventHandlerenqueue theNotification, and a queue handler call the dispatcher — at-least-once delivery with the queue's retry/backoff (DeliveryResult.retryabledecides 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
useFactorybindings 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.
Health & Readiness
Liveness and readiness probes with HealthIndicator, HealthRegistry, and an app-owned controller — DatabaseHealthIndicator, per-indicator timeout, id-keyed results.
Idempotency
Idempotency tiers for event, queue, and command dispatch — EventIdempotencyInterceptor, CommandIdempotencyInterceptor, QueueIdempotencyInterceptor, Idempotency-Key header, ContextData.idempotencyKey, Storage port, best-effort dedup.