Keelv0.86.0
Docs / Mail

Send email through a pluggable transport. Compose a message with a fluent builder and dispatch it — the API mirrors the database layer (setMailer / mail() are to mail what setConnection / db() are to the database). The core imports no SDK: the built-in transports use fetch, console, or memory, so it runs on Node and the edge.

Sending

import { mail } from "@shaferllc/keel/core";

await mail()
  .to("ada@example.com")
  .subject("Welcome aboard")
  .html("<h1>Hi Ada</h1>")
  .send();

Every setter is chainable, and several accept multiple values:

await mail()
  .to("a@x.com", "b@x.com")
  .cc("team@x.com")
  .bcc("audit@x.com")
  .replyTo("support@x.com")
  .from("hello@x.com")        // optional if a default is configured
  .subject("Report")
  .text("Plain-text body")
  .html("<p>HTML body</p>")
  .header("X-Campaign", "weekly")
  .send();

Seed several fields at once with fill():

await mail().fill({ to: "a@x.com", subject: "Hi", text: "body" }).send();

send() resolves to the finalized message — the same object the transport received, with the default from already applied. Handy for logging or assertions:

const sent = await mail().to("ada@example.com").subject("Hi").text("hey").send();
sent.from;    // the resolved from address
sent.to;      // ["ada@example.com"]

Validation & error behavior

A message needs at least one recipient, a subject, a body (text or html), and a fromsend() throws a clear Error otherwise, before the transport is ever called. The checks run in this order:

Missing Message
to (empty) Mail: at least one recipient (to) is required.
subject Mail: a subject is required.
text and html Mail: a text or html body is required.
from (and no default) Mail: a from address is required (set one or a default).

The from default from setMailer(..., { from }) is applied first, so a configured default satisfies the last check without any per-message from.

Configuring the transport

Register a default transport once (typically in a service provider):

import { setMailer, fetchTransport } from "@shaferllc/keel/core";

setMailer(
  fetchTransport({
    url: "https://api.resend.com/emails",
    headers: { Authorization: `Bearer ${env("RESEND_API_KEY")}` },
    body: (m) => ({ from: m.from, to: m.to, subject: m.subject, html: m.html }),
  }),
  { from: "hello@myapp.com" }, // default `from` for messages that omit one
);

fetchTransport POSTs JSON to any provider API (Resend, Postmark, Mailgun, …). The optional body mapper shapes the request for that provider; without it the message is sent as-is. A non-2xx response throws Mail: transport responded <status> <statusText>.

Built-in transports

Transport Use
ArrayTransport Collects messages in .sent — the default, and ideal for tests
LogTransport Logs each message via the logger instead of delivering — local dev
fetchTransport(opts) POSTs to a provider HTTP API via fetch — production

Until you call setMailer, the default mailer is a fresh ArrayTransport — so mail() never throws for want of a transport, it just buffers in memory.

Writing your own transport

A transport is one method:

import type { Transport } from "@shaferllc/keel/core";

const transport: Transport = {
  async send(message) {
    // hand `message` to any SDK or API you like
  },
};
setMailer(transport, { from: "hello@myapp.com" });

The message your send receives is already validated and has from resolved, so a transport can trust every required field is present.

Queueing: sendLater()

Sending is slow and it fails. Holding a request open for an SMTP round trip means the user waits on your provider, and a provider hiccup turns "sign up" into an error page. Put the message on the queue instead:

await mail().to(user.email).subject("Welcome").html(body).sendLater();

The request returns immediately, and a failed send retries on the queue rather than failing the user's action. Everything else is identical — same builder, same transport.

The message is validated at the call site, not on the worker: a missing recipient throws where you composed it, where the stack trace means something, rather than surfacing in a worker log an hour later.

With the default SyncDriver this still sends inline (nothing is deferred until you register a real driver), so sendLater() is safe to adopt before you have a queue.

Attachments

await mail()
  .to("ada@example.com")
  .subject("Your invoice")
  .html('<p>Attached. <img src="cid:logo"></p>')
  .attach("invoice.pdf", pdfBytes)          // content type inferred: application/pdf
  .attach("data.csv", "a,b,c", "text/csv")  // ...or set it
  .embed("logo", logoBytes, "logo.png")     // inline, referenced as cid:logo
  .send();

attach(filename, content, contentType?) takes a string or Uint8Array; the content type is inferred from the extension when you don't give one.

embed(cid, content, filename?, contentType?) is the same thing with a content id, so the HTML body can display it inline via <img src="cid:logo"> instead of linking out to a hosted image.

Class-based mails

A one-liner is fine until the email has real content. BaseMail is to mail what Job is to the queue — a reusable, testable class:

import { BaseMail, type PendingMail } from "@shaferllc/keel/core";

export class WelcomeEmail extends BaseMail {
  constructor(private user: User) {
    super();
  }

  build(message: PendingMail) {
    message
      .to(this.user.email)
      .subject(`Welcome, ${this.user.name}`)
      .html(`<h1>Hi ${this.user.name}</h1>`);
  }
}
import { send, sendLater } from "@shaferllc/keel/core";

await send(new WelcomeEmail(user));
await sendLater(new WelcomeEmail(user)); // ...or queue it

build() may be async, so it can render a template or fetch what it needs.

Multiple mailers

Register mailers by name — a transactional provider and a marketing one, say — and pick one with mail(name):

setMailer(postmark, { from: "hi@app.com" });                    // the default
setMailer(resend, { from: "news@app.com" }, "marketing");

await mail().to(user.email).subject("Receipt").text(body).send();
await mail("marketing").to(user.email).subject("This month").html(body).send();

send(email, name) and sendLater(email, name) take a mailer name too.

In tests

fakeMail() swaps the mailer for one that records instead of delivering, so tests never talk to a provider. restoreMail() puts the real one back.

import { fakeMail, restoreMail } from "@shaferllc/keel/core";

const mailer = fakeMail();

await registerUser();

mailer.assertSent();
mailer.assertSent((m) => m.subject === "Welcome");
mailer.assertSentCount(1);
mailer.assertQueued((m) => m.to.includes("ada@example.com")); // sent with sendLater()
mailer.assertNotSent((m) => m.subject === "Password reset");
mailer.assertNothingSent();

restoreMail();

The fake keeps sent and queued separate — assertSent only matches send(), assertQueued only sendLater() — so a test can tell "we emailed them" from "we queued an email". A faked sendLater() doesn't touch the real queue either; recording the intent is the point.

It still validates the message, so a fake can't paper over a message the real mailer would reject.

mailer.sent() and mailer.queued() return the raw messages if you'd rather assert by hand.

If you want the transport-level view instead, ArrayTransport still works:

const transport = new ArrayTransport();
setMailer(transport, { from: "hi@app.com" });

await mail().to("ada@example.com").subject("Welcome").text("hi").send();

assert.equal(transport.sent[0].subject, "Welcome");

You can also hold your own Mailer instead of the global one — construct it with a transport and reuse it, leaving the process-wide mail() untouched:

import { Mailer, ArrayTransport } from "@shaferllc/keel/core";

const mailer = new Mailer(new ArrayTransport(), { from: "hi@app.com" });
await mailer.message().to("ada@example.com").subject("Hi").text("hey").send();

Events

Every send fires events, so logging, metrics, and auditing can hang off mail without touching the mailer:

Event When
mail.sending before the transport is called
mail.sent after it returns
mail.queued a sendLater() message reached the queue

Each carries the final Message — after defaults are applied.

listen("mail.sent", (message) => logger().info("mail sent", { subject: message.subject }));

The mail layer stands alone, but the database builder shares its shape (setConnection/db mirror setMailer/mail) — the same register-once, call-anywhere pattern.


API reference

mail()

mail(): PendingMail

Starts composing a message on the default (global) mailer.

await mail().to("ada@example.com").subject("Hi").text("hey").send();

Notes: a thin shortcut for getMailer().message(). Uses whatever transport and options were last passed to setMailer (an in-memory ArrayTransport if you never called it).

setMailer(transport, options?)

setMailer(transport: Transport, options?: MailerOptions): Mailer

Replaces the global mailer with a new one built from transport and options, and returns it.

setMailer(fetchTransport({ url }), { from: "hello@myapp.com" });

Notes: global — the last call wins. Returns the constructed Mailer if you want a direct handle. options defaults to {} (no default from).

getMailer()

getMailer(): Mailer

Returns the current global Mailer instance.

const mailer = getMailer();
await mailer.message().to("ada@example.com").subject("Hi").text("hey").send();

Notes: before any setMailer call this is a Mailer wrapping a fresh ArrayTransport.

fetchTransport(options)

fetchTransport(options: FetchTransportOptions): Transport

Builds a Transport that POSTs each message as JSON to a provider HTTP API via fetch.

const transport = fetchTransport({
  url: "https://api.resend.com/emails",
  headers: { Authorization: `Bearer ${apiKey}` },
  body: (m) => ({ from: m.from, to: m.to, subject: m.subject, html: m.html }),
});

Notes: always sets Content-Type: application/json; your headers merge on top. Without a body mapper the raw Message is serialized. Throws Mail: transport responded <status> <statusText> on any non-ok response.

Mailer

The engine that validates a message, applies defaults, and hands it to the transport. Construct one directly (new Mailer(transport, options?)) for a scoped mailer, or reach the global one via getMailer() / setMailer().

new Mailer(transport, options?)

new Mailer(transport: Transport, options?: MailerOptions)

Wraps a transport and its options.

const mailer = new Mailer(new ArrayTransport(), { from: "hi@app.com" });

Notes: options defaults to {}. The transport is fixed for this instance — build a new Mailer to swap it.

message()

message(): PendingMail

Starts a new PendingMail bound to this mailer.

const pending = mailer.message();

Notes: each call returns a fresh builder; nothing is shared between messages.

send(message)

send(message: Message): Promise<Message>

Applies the default from, validates the message, dispatches it through the transport, and resolves to the finalized message.

const sent = await mailer.send({ to: ["ada@x.com"], subject: "Hi", text: "hey" });

Notes: throws (before touching the transport) if to is empty, or subject, a body, or from is missing — see Validation. PendingMail.send() funnels through here. The returned object is a shallow copy with from resolved.

PendingMail

The fluent builder. You get one from mail() or mailer.message(), never new. Every setter returns this, so calls chain in any order; nothing is sent until send().

to(...addresses)

to(...addresses: string[]): this

Appends one or more recipients.

mail().to("a@x.com", "b@x.com");

Notes: additive — repeated calls accumulate recipients rather than replace.

from(address)

from(address: string): this

Sets the sender, overriding the mailer's default from.

mail().from("hello@x.com");

Notes: a single value (not variadic). Optional when a default from is configured on the mailer.

cc(...addresses) / bcc(...addresses)

cc(...addresses: string[]): this bcc(...addresses: string[]): this

Append carbon-copy / blind-carbon-copy recipients.

mail().cc("team@x.com").bcc("audit@x.com");

Notes: both additive, like to. The underlying arrays are created lazily on first use.

replyTo(address)

replyTo(address: string): this

Sets the Reply-To address.

mail().replyTo("support@x.com");

Notes: a single value; a later call replaces the prior one.

subject(subject)

subject(subject: string): this

Sets the subject line.

mail().subject("Welcome aboard");

Notes: required — send() throws if it's empty. A later call replaces it.

text(text) / html(html)

text(text: string): this html(html: string): this

Set the plain-text / HTML body. At least one is required.

mail().text("Plain body").html("<p>Rich body</p>");

Notes: you can set both (a multipart message); send() throws only if neither is present. Each later call replaces its body.

header(name, value)

header(name: string, value: string): this

Adds a custom header.

mail().header("X-Campaign", "weekly");

Notes: additive per name — repeated calls with distinct names accumulate; the same name overwrites. The headers object is created lazily.

fill(partial)

fill(partial: Partial<{ to: string | string[]; cc: string | string[]; bcc: string | string[] } & Omit<Message, "to" | "cc" | "bcc">>): this

Seeds several fields at once, merging into whatever's been chained.

mail().fill({ to: ["a@x.com", "b@x.com"], subject: "Hi", text: "body" });

Notes: to/cc/bcc accept a single string or an array and are appended to any existing recipients. The other fields (from, subject, text, html, replyTo, headers) are assigned, replacing prior values — passing headers here overwrites the whole header map rather than merging.

send()

send(): Promise<Message>

Hands the composed message to the mailer and resolves to the finalized message.

const sent = await mail().to("ada@x.com").subject("Hi").text("hey").send();

Notes: delegates to Mailer.send, so the same validation and default-from handling apply; it throws on a missing required field.

ArrayTransport

An in-memory transport that records every message. The default transport, and the one to use in tests.

new ArrayTransport()

new ArrayTransport()

Creates a transport with an empty sent array.

const transport = new ArrayTransport();

sent

readonly sent: Message[]

The messages this transport has received, in order.

const transport = new ArrayTransport();
setMailer(transport);
// ...after sending...
transport.sent.length;       // number of messages queued
transport.sent[0]?.subject;  // first message's subject

Notes: readonly binding but the array is mutated on each send — assert on .length and elements.

send(message)

send(message: Message): Promise<void>

Pushes the message onto sent.

await new ArrayTransport().send(message);

Notes: never throws; delivers nothing. Called for you by Mailer.send.

LogTransport

A transport that logs each message (to, from, subject) via the framework logger instead of delivering it — for local development.

new LogTransport()

new LogTransport()

Creates the transport.

setMailer(new LogTransport(), { from: "dev@localhost" });

send(message)

send(message: Message): Promise<void>

Logs to, from, and subject at info level; sends nothing.

await new LogTransport().send(message);

Notes: the body is not logged, only the envelope fields.

Interfaces & types

Message

interface Message {
  to: string[];
  from?: string;
  cc?: string[];
  bcc?: string[];
  replyTo?: string;
  subject: string;
  text?: string;
  html?: string;
  headers?: Record<string, string>;
}

The normalized, ready-to-send message. The builder produces one; a Transport receives one (already validated, with from resolved). You can also build one by hand and pass it to Mailer.send.

const message: Message = {
  to: ["ada@x.com"],
  from: "hi@app.com",
  subject: "Hi",
  text: "hey",
};

Transport

interface Transport {
  send(message: Message): Promise<void>;
}

The seam between the mailer and your email provider — one method. Implement it to bridge any SDK or API; register it with setMailer.

const transport: Transport = {
  async send(message) {
    await myProviderSdk.emails.send(message);
  },
};
setMailer(transport, { from: "hi@app.com" });

MailerOptions

interface MailerOptions {
  from?: string;
}

Options for a Mailer. Currently just a default from applied to messages that don't set one.

setMailer(transport, { from: "hello@myapp.com" });

FetchTransportOptions

interface FetchTransportOptions {
  url: string;
  headers?: Record<string, string>;
  body?: (message: Message) => unknown;
}

Configuration for fetchTransport. url is the provider endpoint; headers merge over the automatic Content-Type: application/json; body maps a Message to the provider's request shape (defaults to the message itself).

const opts: FetchTransportOptions = {
  url: "https://api.resend.com/emails",
  headers: { Authorization: `Bearer ${apiKey}` },
  body: (m) => ({ from: m.from, to: m.to, subject: m.subject, html: m.html }),
};

mailer(name?)

mailer(name?: string): Mailer — the default mailer, or a named one. Throws for an unknown name.

send(email, name?) / sendLater(email, name?)

send(email: BaseMail, name?: string): Promise<Message> — build a class-based mail and send it. sendLater queues it instead.

BaseMail

Abstract. Implement build(message: PendingMail): void | Promise<void> to compose the message.

PendingMail.sendLater()

sendLater(): Promise<void> — validate now, then put the message on the queue.

PendingMail.attach() / .embed()

attach(filename, content: string | Uint8Array, contentType?): this — content type inferred from the extension when omitted.

embed(cid, content, filename?, contentType?): this — an inline attachment, referenced from the HTML as cid:<cid>.

PendingMail.toMessage()

toMessage(): Message — the message as composed, before the mailer applies its defaults.

Testing

fakeMail(name?) / restoreMail(name?)

fakeMail(name?): FakeMailer swaps a mailer for one that records instead of delivering. restoreMail(name?) puts the real one back — with no name, every faked mailer.

FakeMailer:

Method Signature
assertSent (where?) => void
assertNotSent (where?) => void
assertSentCount (count) => void
assertQueued (where?) => void
assertNotQueued (where?) => void
assertQueuedCount (count) => void
assertNothingSent () => void — nothing sent and nothing queued
sent() / queued() () => Message[]

Interfaces & types

Attachment

{ filename, content: string | Uint8Array, contentType?, cid? } — a cid makes it an inline attachment.

MailerOptions

{ from?, replyTo? } — defaults applied to messages that don't set their own.

RecordedMail

{ message: Message, queued: boolean } — what a FakeMailer records.

SendMailJob

The Job that carries a queued message. Exported so a custom queue driver can recognize it.