provider
Single IEmailService interface
Adding Postmark or SES later is one file plus one selector branch.
Three layers:
.hbs files precompiled to JSON at build time.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
Adding Postmark or SES later is one file plus one selector branch.
dev
Dev boots without credentials; tests stay deterministic.
templates
No Handlebars parser in the hot path and no template injection from user data.
dispatch
Workers skip the queue; request handlers go through it when queues are enabled.
privacy
Email addresses never reach the log pipeline raw.
default
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 },});$ 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 credentialsDo not branch on queue vs inline in the handler; that decision lives in env config and sendTemplate / sendTemplateNow.
src/lib/email/providers/<name>.ts implementing IEmailService.EmailProviderName union and the switch in buildEmailService().The HTTP call should be wrapped in retryWithBackoff so transient 5xx responses get retried inside the call, not just at the queue level.
.hbs file in the right subfolder.bun run build:templates (or the watcher in dev).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.
email-delivery queue on the worker side.