Skip to content
BoringStack
GitHub

Email

4 min read

Provider-swappable email

Email has a delivery spine without forcing one vendor. Providers, templates, and dispatch are independently swappable, while request handlers keep calling the same function.

5

providers shipped

JSON

precompiled templates

queue

or inline dispatch

Three layers:

  1. Provider: the wire to whoever actually delivers the mail.
  2. Templates: Handlebars .hbs files precompiled to JSON at build time.
  3. Dispatch: sendTemplate(...) chooses queue or inline based on env, so the call site doesn’t care which.

The default is Cloudflare Email Service because it’s the cheapest at scale. Resend, SendGrid, and a plain SMTP provider ship alongside; swapping is a one-env-var change. SMTP is what you use locally against Mailpit.

flowchart LR
  caller["caller<br/>sendTemplate(...)"] --> dispatch{QUEUES_ENABLED?}
  dispatch -- yes --> queue["enqueue<br/>email-delivery"]
  queue --> worker["worker<br/>processJob"]
  worker --> render["render template<br/>(precompiled JSON)"]
  dispatch -- no --> render
  render --> provider["provider.send()<br/>retryWithBackoff"]
  provider -- cloudflare/resend/sendgrid/smtp --> sent[(provider)]
  provider -- noop / missing key --> log["log only"]

Two retry layers stack when queues are on: the inner retryWithBackoff handles flickery HTTP responses, the outer BullMQ retry handles the case where the whole provider is down for minutes.

provider

Single IEmailService interface

Adding Postmark or SES later is one file plus one selector branch.

dev

noop provider fallback

Dev boots without credentials; tests stay deterministic.

templates

Precompiled to JSON

No Handlebars parser in the hot path and no template injection from user data.

dispatch

sendTemplate is queue-aware

Workers skip the queue; request handlers go through it when queues are enabled.

privacy

Address masking in logs

Email addresses never reach the log pipeline raw.

default

Cloudflare first

The default provider is cheap at scale, but the app contract stays provider-agnostic.

Every concrete provider implements one shape:

interface IEmailService {
send: (msg: { to; subject; html; text? }) => Promise<{ id; provider }>;
readonly providerName: "cloudflare" | "resend" | "sendgrid" | "smtp" | "noop";
}

The selector reads EMAIL_PROVIDER. If the matching key is empty, it returns the noop provider; dev never crashes, prod boot fails earlier at the env validator.

Authors write .hbs files in src/templates/email/templates/{auth,notifications}/. The build script (bun run build:templates) compiles them to JSON. At runtime the template service reads the JSON and invokes the precompiled function. Net effect: zero parse cost per send, no template-injection surface.

Shared layout partials live in components/. baseTemplateVariables() injects common context (product name, support URL, current year) so templates don’t repeat it.

import { sendTemplate } from "../lib/email";
await sendTemplate({
to: user.email,
subject: "Verify your email",
templatePath: "auth/verify-email",
variables: { token, confirmationUrl },
});
Provider switch
$ EMAIL_PROVIDER=cloudflare bun run dev
$ EMAIL_PROVIDER=smtp WITH_MAILPIT=1 ./dev.sh up -d

#   cloudflare: production default when API token and account id exist
#   smtp: local Mailpit loop with no external credentials

Do not branch on queue vs inline in the handler; that decision lives in env config and sendTemplate / sendTemplateNow.

  1. Create src/lib/email/providers/<name>.ts implementing IEmailService.
  2. Add it to the EmailProviderName union and the switch in buildEmailService().
  3. Add the API-key env var to the schema with the matching cross-field invariant.

The HTTP call should be wrapped in retryWithBackoff so transient 5xx responses get retried inside the call, not just at the queue level.

  1. Drop a .hbs file in the right subfolder.
  2. Run bun run build:templates (or the watcher in dev).
  3. Reference it: templatePath: "<subfolder>/<name>".

@boring-stack-pkg/eslint-plugin-structured-logging fails the build on unmasked email addresses in log calls or console.log-style leaks.

src/lib/email/; providers, dispatch, template service. src/templates/email/; the .hbs sources and build pipeline.