Packages

Email

A Resend email port with a test driver.

@caisson/email is the transactional-email port. Resend is the default driver (Postmark, SMTP, and SES ship alongside it) and every driver sits behind the port like every other vendor in the base: call sites depend on the port, not the SDK.

The contract

A capture driver records sends in memory, so a suite asserts on what would have been emailed (recipient, template, payload) without a network call or a live key.

import { createResendEmailer } from "@caisson/email";

const email = createResendEmailer({
  apiKey: env.RESEND_API_KEY,
  from: "[email protected]",
});

await email.send({
  to: user.email,
  template: "verify-email",
  data: { url },
});

Under the standards gate, tests wire the capture driver instead and assert on what was sent: never a network call, never a live key:

import { createCaptureEmailer } from "@caisson/email";

const email = createCaptureEmailer();
await email.send({ to: user.email, template: "verify-email", data: { url } });

// The send is captured, not transmitted.
expect(email.sent).toHaveLength(1);

API reference

The port

interface EmailMessage {
  to: string;
  template: string;
  data: Record<string, unknown>;
}

interface Emailer {
  send(msg: EmailMessage): Promise<void>;
}

One method, provider-agnostic, every driver below implements it. template is free-form on the port itself: a caller can pass one of the branded template ids (rendered below) or an arbitrary name, @caisson/alerting sends operator alerts through this same port with its own alert.* namespace, and every driver falls back to a generic subject/text mapping when template isn't a known id.

Drivers

function createCaptureEmailer(): CaptureEmailer;

interface CaptureEmailer extends Emailer {
  readonly sent: readonly EmailMessage[];
}

In-memory driver for tests. send pushes onto a private array exposed read-only via sent (preserving order) and never touches the network, the framework-agnostic reference implementation of the port.

function createResendEmailer(config: ResendConfig): Emailer;
interface ResendConfig {
  apiKey: string;
  from: string;
}

Production driver backed by Resend. apiKey is injected config, never a module-level constant. send renders the branded template when template matches a known id, then POSTs through fetchWithTimeout. A non-ok response throws InternalError: the response body is never included in the thrown error, since a Resend error can echo the recipient or a key fragment back.

function createPostmarkEmailer(config: PostmarkConfig): Emailer;
interface PostmarkConfig {
  serverToken: string;
  from: string;
}

Production driver backed by Postmark. Same shape as the Resend driver, fetchWithTimeout, InternalError on a non-ok response, and the response body withheld for the same reason.

function createSmtpEmailer(config: SmtpConfig): Emailer;
interface SmtpConfig {
  host: string;
  port: number;
  user: string;
  pass: string;
  from: string;
  secure?: boolean; // TLS-on-connect (port 465); defaults false (STARTTLS on 587/25)
  transport?: SmtpTransport; // inject a fake to test without a real SMTP connection
}

Production driver backed by nodemailer. Renders through the same template registry as the REST drivers, falling back to the generic subject/text mapping for a free-form template.

function createSesEmailer(config: SesConfig): Emailer;
function sesSmtpConfig(config: SesConfig): SmtpConfig;
interface SesConfig {
  region: string;
  smtpUser: string;
  smtpPass: string;
  from: string;
  transport?: SmtpTransport;
}

AWS SES driver. SES exposes an SMTP interface, so createSesEmailer is createSmtpEmailer pointed at SES's regional endpoint (email-smtp.<region>.amazonaws.com:587, STARTTLS), there is no separate aws-sdk dependency. sesSmtpConfig is the pure config mapper underneath it, exposed as the testable seam.

Templates

Every driver renders through one React-Email registry, so the HTML body and the plain-text fallback are generated from the same component and can never drift apart.

const EMAIL_TEMPLATE_IDS: readonly EmailTemplateId[];

type EmailTemplateId =
  | "magic-link"
  | "password-reset"
  | "verify-email"
  | "credits-expiring"
  | "updates-window-expiring"
  | "purchase-confirmation"
  | "subscription-payment-received"
  | "renewal-confirmation"
  | "access-revoked"
  | "waitlist-welcome"
  | "nurture-follow-up";

function isEmailTemplateId(template: string): template is EmailTemplateId;

EMAIL_TEMPLATE_IDS is the stable display order the admin catalog preview walks. isEmailTemplateId is the runtime membership check, a request-supplied string is never trusted without it.

function renderEmailTemplate<K extends EmailTemplateId>(
  template: K,
  data: TemplateDataMap[K],
): Promise<RenderedEmail>;

interface RenderedEmail {
  subject: string;
  html: string;
  text: string;
}

Renders one branded template against its typed prop shape (TemplateDataMap keys each id to its own data type, a purchase receipt's line items are not a magic-link's url). Throws if the template id isn't in the registry; callers with an unchecked template string use tryRenderEmailTemplate instead.

function tryRenderEmailTemplate(
  template: string,
  data: Record<string, unknown>,
): Promise<RenderedEmail | null>;

The driver-facing entry point. Returns null (never throws) when template isn't a known branded id, or when data doesn't structurally match that template's shape, so a free-form alert or a malformed payload falls back to the generic subject/text mapping instead of breaking the send. Every production driver's send calls this first, the capture driver records the raw message without rendering.

const EMAIL_SAMPLE_DATA: { [K in EmailTemplateId]: TemplateDataMap[K] };

One realistic payload per template id, shared by the admin catalog preview, the admin send-test route, and the visual harness, so a preview, a test send, and a screenshot always render off the same data.