@atlas/email#

Provider-agnostic email transport plus a minimal HTML email shell and two ready-to-use templates (invite, password reset).

Transports#

import { createEmailer, createResendEmailer, createConsoleEmailer } from "@atlas/email"

// auto-detect: real Resend transport when both env vars are set, otherwise console
const emailer = createEmailer({
  apiKey: process.env.RESEND_API_KEY,
  from: process.env.RESEND_FROM,
})

// or pick directly
const real = createResendEmailer({ apiKey: KEY, from: "[email protected]" })
const dev  = createConsoleEmailer()  // logs subject/body, returns ok: true, logged: true

The console transport is the default fall-through when apiKey or from are blank — keeps dev environments unblocked without configuring a sending domain.

type Emailer = {
  readonly enabled: boolean
  readonly send: (msg: EmailMessage) => Promise<SendResult>
}

type EmailMessage = {
  readonly to: string | readonly string[]
  readonly subject: string
  readonly html: string
  readonly text?: string
  readonly replyTo?: string
  readonly from?: string  // overrides the transport default
}

type SendResult = { ok: true; id?: string; logged?: boolean } | { ok: false; error: string }

send never throws — failures come back as { ok: false, error }. Surface those to the caller (or audit-log them) rather than ignoring.

Templates#

import { inviteEmail, passwordResetEmail } from "@atlas/email"

const invite = inviteEmail({
  inviterName: "Wess",
  product: "Atlas",
  signupUrl: "https://app.example.com/signup?invite=…",
  note: "Excited to have you!",
  brand: "Atlas",       // optional, shown at the top of the card
  accent: "#5e81ac",    // optional, color of buttons / links
})

await emailer.send({ to: "[email protected]", ...invite })

Templates return { subject, html, text } so they drop straight into Emailer.send. Both inviteEmail and passwordResetEmail accept brand and accent for theming; omit them for an unbranded card.

Layout helper#

import { layout, escapeHtml } from "@atlas/email"

const html = layout({
  title: "Welcome",
  body: `<p>Hi ${escapeHtml(user.name)} — glad you're here.</p>`,
  brand: "Atlas",
  footer: "Atlas · self-hostable apps",
  accent: "#5e81ac",
})

layout is a single-column 560px card with a gentle Nord-flavored palette, inline-friendly styles (works in Outlook), and a button class (<a class="btn">…). Always pass user-supplied strings through escapeHtml before interpolating.

Dependencies#

Zero — uses fetch (Bun / Node 18+). No SDK dependencies.

Adding a provider#

The Emailer interface is the only contract. To add Postmark, SES, etc., write a createXxxEmailer(opts) that returns { enabled, send } and falls back to createConsoleEmailer() when not configured.

Testing#

bun test packages/email/
Canonical sourcepackages/email/AGENTS.md
Type to search guides and package references.