Send an Email
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:
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() };
}
}
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.
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:
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
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.
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
NotificationDispatcherfor fanning one logical notification across channels. - Background Jobs — send from an
IntegrationEventHandlerinstead 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.
Advanced DI
The nominal-satisfier walk (a concrete subclass satisfies an abstract token, AmbiguousProviders on a tie), array-provide, useExisting aliasing, useFactory with multiple dependencies, the generic-token pitfall (DrizzleLibSQLConfig and named subclasses), useValue with eager, factory-form Module((config) => ({...})) configs, and overriding the ErrorEnvelope.
Upload a File
Accept a multipart/form-data upload validated by Route.files(), read it with readValidatedFormData(), and stream it into the abstract BlobStore DI token — swappable for filesystem, S3, or R2 with no controller change.