All pages

Add an email

Files you will touch, in this order:

  1. packages/shared/src/mail.ts, the mail kind
  2. apps/api/src/lib/email/templates/<name>.tsx, the template and its props
  3. apps/api/src/lib/email/send-<name>.ts, the send function
  4. apps/api/src/lib/email/index.ts, the barrel
  5. the caller

Four emails ship, and the folder is arranged so that a fifth is a visible act. deliver and the Resend client are deliberately not re-exported from the barrel, so mail is constructed inside this folder or not at all, and "how many emails does this app send" stays answerable by reading one directory.

Write the template

// apps/api/src/lib/email/templates/seat-invite.tsx

import {
  Body, Container, Head, Heading, Html, Preview, Text,
} from '@react-email/components'
import { container, heading, main, paragraph } from './styles'

export type SeatInviteProps = {
  /** The name of the team the recipient is being added to. */
  teamName: string
  /** Where they accept. Built by the caller, never read from env in here. */
  acceptUrl: string
}

export function SeatInvite({ teamName, acceptUrl }: SeatInviteProps) {
  return (
    <Html lang="en">
      <Head />
      <Preview>{`You have been added to ${teamName}`}</Preview>
      <Body style={main}>
        <Container style={container}>
          <Heading style={heading}>You have a seat on {teamName}</Heading>
          <Text style={paragraph}>
            Open {acceptUrl} while signed in to accept it.
          </Text>
        </Container>
      </Body>
    </Html>
  )
}

Styles come from ./styles.ts rather than from Tailwind classes. Email clients do not run a CSS pipeline, so the shipped templates use inline style objects and the existing ones are the reference for what renders.

Nothing in a template reads the environment. The repository owner and name arrive as props on the invite mail, because the caller is the one that actually invited a handle to a repository, and a mail naming a different repository than the invite is worse than no mail. The reference implementation hard-coded a personal repository into the invite path, so every fork pointed its buyers at somebody else's.

Carry no brand name in the copy either. The sender identity lives in EMAIL_FROM and nowhere else.

Write the send function

// apps/api/src/lib/email/send-seat-invite.ts

import { createElement } from 'react'
import { deliver, type SendResult } from './client'
import { SeatInvite, type SeatInviteProps } from './templates/seat-invite'

export type SendSeatInviteOptions = SeatInviteProps & {
  /** Where the mail goes. */
  to: string
  /** What the mail reports on, for the log line. */
  seatId: string
}

export function sendSeatInvite(options: SendSeatInviteOptions): Promise<SendResult> {
  const { to, seatId, ...invite } = options

  return deliver({
    kind: 'seat-invite',
    subjectId: seatId,
    to,
    subject: `You have a seat on ${invite.teamName}`,
    body: createElement(SeatInvite, invite),
  })
}

The options type is the template's props plus a recipient, so the two cannot drift: adding a field to the mail is a type error at the call site rather than a blank space in the rendered copy.

kind is what reaches the log line and the claim row, and it is one runtime array you widen:

// packages/shared/src/mail.ts

export const MAIL_KINDS = [
  'invite',
  'receipt',
  'access-at-risk',
  'access-ended',
  'trial-ending',
  'seat-invite',
] as const

Mail in client.ts and the kind column on email_deliveries both read that array, so a kind that is not in it fails to compile rather than reaching Resend.

subjectId is the id of the thing the mail reports on, not the subject line. Every event carries it, email.sent included, so a support question about one person or one payment can be answered from the log rather than from Resend. Pick the id the reader would name: the Purchase for a receipt, the user for anything about their access.

The recipient is deliberately not logged. The access log already refuses to log an email address that arrives in a query string, and a mail log line is not a better place for one. The Resend id on email.sent is the handle you follow into their dashboard.

Export it

// apps/api/src/lib/email/index.ts

export * from './send-seat-invite'

Export the send function, not deliver and not the client.

Call it

const result = await sendSeatInvite({
  to: member.email,
  teamName: team.name,
  acceptUrl: `${env.WEB_URL}/teams/${team.id}/accept`,
})

if (!result.sent) {
  // the log line is already written; branch only if you have somewhere to put it
}

deliver reports rather than throws:

type SendResult =
  | { sent: true; id: string }
  | { sent: false; reason: 'not_configured' | 'send_failed' }
Warning

Do not make a send throw. Every caller in this repo is a side effect of something that already happened, so a throw undoes work that succeeded. Resend has the two cases that shaped this.

If your caller genuinely cannot proceed without the mail, that is a different design: send it first, and let the failure prevent the work rather than undo it.

not_configured and send_failed stay separate because the first is a buyer who declined email and is not an incident, and the second is one.

Send it after the work, not before

await markDelivered(row)
await sendInvite({ to: row.email, /* ... */ })

That order is what the shipped invite does, and it is the general rule. A mail that failed must not cost somebody the thing the mail was announcing.

Guard repeated delivery attempts

An email is not convergent: sending it twice sends two emails, and no amount of re-fetching fixes that. Stripe delivers the same event more than once, and invoice.payment_failed fires once per Smart Retry, so a mail on either trigger arrives repeatedly without a claim.

Wrap the send in deliverOnce, which inserts one email_deliveries row per (kind, stripeObjectId) and sends only when the insert wins:

await deliverOnce(
  { kind: 'receipt', stripeObjectId: charge.id, userId: purchase.userId },
  () => sendReceipt({ to, locale: purchase.locale, /* ... */ }),
)

Retrieve and validate any vendor data before calling it, so a failed lookup leaves the claim available. The claim is at-most-once: a send failure after claiming is logged and never retried, and deleting the row is the only way to permit a repeat. An unconfigured mailer is the exception, because it attempted no send, so the row is released and the mail is still available to a fork that later sets RESEND_API_KEY. Reliable email delivery needs a separate retry and idempotency contract.

A mail with no Stripe object to key on claims through whatever exclusive thing triggers it instead. The shipped invite sends inside the Fulfillment claim and takes no row of its own. See Fulfillment for claim recovery.

Verify

pnpm typecheck
pnpm check

Then trigger it with RESEND_API_KEY unset first. The log should carry email.skipped with reason: 'not_configured' and nothing should throw, because that is the path a buyer who declined email takes.

Then set the key and trigger it again. email.sent carries the Resend id, and the message is in your Resend dashboard with its rendered HTML.

  • Resend, the account setup and what ships
  • docs/adr/0014-two-tier-env-schema.md, why email is optional
  • docs/adr/0007-atomic-claim-for-side-effects.md, exclusive claims and recovery limits
  • docs/adr/0032-mail-follows-entitlement-not-invoices.md, one claim mechanism for every mail

Updated