Recipe: transactional email

Render cascivo-themed email with @cascivo/email. Your product UI and the mail it sends share one design system: the same twelve themes, the same tokens, resolved for clients that have never heard of oklch().

This is the workflow end to end. For the components themselves — every primitive with its props, its defaults and a worked example — see EMAIL-PRIMITIVES.md, or the rendered previews at cascivo.com/docs/email/components.

For what the clients actually support, see EMAIL-CLIENT-SUPPORT.md — generated from the same data the conformance lint reads, so the two cannot disagree.

Install

pnpm add @cascivo/email react react-dom

react-dom is a peer because the render goes through renderToStaticMarkup. Nothing else is required — the package has no runtime dependencies of its own.

Send one

import { assertSendable, renderEmail, PasswordReset, passwordResetSubject } from '@cascivo/email'

const message = renderEmail(<PasswordReset resetHref={link} />, {
  theme: 'dark',
  subject: passwordResetSubject(),
  tier: 'strict',
})

assertSendable(message)
await mailer.send({ to, ...message })

renderEmail returns the whole message — subject, html, text and preheader — not just a document. The subject comes from the template rather than the call site on purpose: the subject, the <title> and the preheader are three facets of one message, and splitting them across two files is how they drift apart.

Always send text alongside html. Some clients are text-only, some readers prefer it, and a missing text part is a documented spam-filter signal.

assertSendable catches the four things that only reveal themselves once the mail has arrived: no subject, no text part, no preheader (the client then shows the first words of the body), and a body over the clip threshold. It is a separate call rather than something renderEmail does, because a preview renders half-finished templates on every keystroke and must not throw.

sendEmail(sender, message, envelope) runs assertSendable and refuses line breaks in every address and header, then hands the message to a sender: Cloudflare's Email Service binding (env.EMAIL), or the Amazon SES client from @cascivo/app/ses. Taking payments and sending through SES from a Worker, with bounces and complaints handled, is its own recipe: RECIPE-PAYMENTS.md.

The preheader

<Preview> is the grey line beside the subject in the inbox. Without it the client shows the opening words of your body, which usually reads as the subject said twice.

<Preview>{`Reset your password — the link expires in ${minutes} minutes`}</Preview>

It is extracted back out of the rendered HTML as message.preheader, so you can assert on it rather than trust it.

The plain-text part

Derived automatically, and structured rather than tag-stripped: headings are underlined, list items keep a marker, rules survive as rules, and link destinations are carried inline.

renderEmail(<Digest />, { text: { links: 'footnote', width: 72 } })

Mark anything that only makes sense visually with data-skip-in-text and it will be left out of the text part, the same escape hatch React Email offers.

Supply plainText instead if you would rather write it by hand.

Sending it yourself, or opening it in a real client

import { buildMessage } from '@cascivo/email'

const eml = buildMessage(message, { from: '[email protected]', to: recipient })

A multipart/alternative message: text part first (RFC 2046 orders alternatives least- to most-faithful, and clients take the last they can render), quoted-printable encoded, non-ASCII subjects RFC 2047 encoded, and header injection refused rather than silently neutralised.

Write it to a .eml file and drag it into Outlook or Apple Mail — the cheapest way to see the mail in a real client, with no service involved.

Write one

Templates are ordinary compositions of the primitives:

import { Body, Button, Container, Head, Heading, Html, Preview, Section, Text } from '@cascivo/email'

export function Invite({ href, team }: { href: string; team: string }) {
  return (
    <Html>
      <Head title={`Join ${team}`} />
      <Body>
        <Preview>{`You have been invited to join ${team}`}</Preview>
        <Container>
          <Section padding={32}>
            <Heading level={1}>{`Join ${team}`}</Heading>
            <Text>A teammate invited you. The link below expires in seven days.</Text>
            <Button href={href}>Accept the invitation</Button>
          </Section>
        </Container>
      </Body>
    </Html>
  )
}

Prose you do not have at build time

Composing the primitives is right when you know the copy. It is not an option when you do not — a newsletter preamble an editor writes, a CMS-driven confirmation letter — because there is nothing to compose at build time. Markdown covers that case:

import { Markdown, Section } from '@cascivo/email'

<Section padding={24}>
  <Markdown imageWidth={552}>{issue.preamble}</Markdown>
</Section>
MarkdownRenders as
# … — ###### …Heading at that level
paragraphText
**bold**, __bold__<strong>
*italic*, _italic_<em>
`code`<code> in the mono stack
```fenced```<pre> in a padded cell
[text](https://…), <url>Link
![alt](https://…)Img, at the imageWidth prop's width
- item, 1. itemList
> quotean accented block, nestable
---Hr

The allowlist is the point rather than a limitation. The reason there was no Markdown component for so long is that arbitrary Markdown produces arbitrary HTML, and the conformance lint cannot vouch for HTML it has no primitive for. This renders every node through the primitives and never as HTML, so the lint covers a Markdown subtree exactly as it covers a hand-composed one. Three consequences worth knowing before you ship it:

Images carry no dimensions in Markdown and Img requires a width, so imageWidth supplies one — pass the inner width of your container. A fenced block does not wrap, because css-white-space is unsupported in Outlook Windows; keep those lines short.

It has to survive a phone

A 600px email in a 320px viewport is the most common way a template ships broken, and the one the conformance lint cannot catch — it reads CSS feature support, and this is layout. max-width: 100% looks like it handles it and does not: a table will not lay out below the min-content width of what is inside it.

Two rules do handle it, and they are not interchangeable.

Container is responsive by default. It emits a width override below its own width, so the wrapper goes fluid on a phone. Pass responsive={false} if you are supplying your own, or breakpoint={620} when a design spec names a number of its own and you would rather the markup said the same one.

Columns have to be told to stack. A Row keeps the min-content width of its columns however fluid its container is, so a two-column layout still overflows until each column opts in:

<Row>
  <Column width="50%" stack>…</Column>
  <Column width="50%" stack>…</Column>
</Row>

It is opt-in because not every row should reflow — a logo beside a date is meant to stay on one line at any width.

Measured at 320px, a two-column newsletter overflowed by 280px with the container override alone, and by nothing once the columns stacked.

A fixed-width image still sets a floor. A 240px image in a 24px-padded section cannot go below 304px however fluid its ancestors are, because a sized replaced element contributes its own width to min-content. Size images for the narrowest column they will occupy.

@media is n in exactly one floor client, Outlook Windows — which is desktop-only and renders at a width where the fixed layout is already right, so these rules are progressive enhancement whose absence costs nothing.

Rules of your own

Media queries and pseudo-classes are the two things an inline style genuinely cannot express, which is the only reason a <style> block exists here at all. Container, Container, Section, Row, Column, Card and Text take a className to give a rule something to select, and Style puts the rule somewhere — wherever you write it, it is hoisted into <head> and merged with every other one:

import { Column, Row, Style } from '@cascivo/email'
;<>
  <Style>{`@media only screen and (max-width:600px){.hide-sm{display:none!important}}`}</Style>
  <Row>
    <Column className="hide-sm">…</Column>
  </Row>
</>

Card and Text are on that list because they are the two the common cases need: a panel whose padding shrinks below the breakpoint, and a hero line that comes down a size. The first cut gave classes to the layout primitives only, which read as a principled split and left out both.

Use a class, not the [style*='--flag'] attribute-selector trick: css-selector-attribute is n in Outlook Windows where css-selector-class is merely partial in two Gmail apps, so the workaround is worse supported than the plain thing it stands in for.

The rules that are not negotiable

These are enforced, not advisory. The conformance lint fails the build on the first, and the structural invariants in packages/email/src/render/render.test.tsx on the rest.

Preview it

npx @cascivo/email-preview ./emails

Point it at a directory and every .tsx or .jsx file in it becomes a template: the default export is rendered, and the optional named exports subject (a string) and previewProps (the props to preview with) are used if present. Files matching .test., .spec., .stories. or a leading _ are skipped. Run it with no directory to browse the templates that ship with the package.

Your templates go through Vite, so edits hot-reload — the loop is edit, look, edit.

You get a viewport switcher, all twelve themes, per-client simulation, a live encoded-byte gauge with the clip thresholds drawn on it, and an .eml download. That last one is the cheapest way to see an email in a real client: download it and drag it into Outlook, Apple Mail, or anything else on any device. No service, no account.

A template can declare the palette it was designed in, beside subject and previewProps:

export const theme = { ...PALETTES.light, '--cascivo-color-accent': '#b4381e' }

Do it even with one brand. renderEmail's theme takes a Palette, which is how you rebrand — so without this every render in the preview is somebody else's email, and the byte gauge is wrong by whatever your palette costs. --theme <file> adds shared palettes to the dropdown for a whole directory, and --allow <file> lines the conformance panel up with whatever your CI waives.

For the conformance findings — the same ones CI reports — hand it the Can I email matrix, which is not bundled because it is ~483 KB of test data:

curl -o caniemail.json https://www.caniemail.com/api/data.json
npx @cascivo/email-preview ./emails --caniemail caniemail.json

Without it everything else still works and the panel says where to get one, rather than showing a clean bill of health it has not checked.

--port, --host and --open do what you would expect; --help lists them.

Check it against the clients

lint() reads a finished render and reports anything a floor client cannot support. It needs the Can I email matrix, which is not bundled (~483 KB of test data), so the CLI does the fetching, caching and wiring for you:

cascivo email lint dist/emails/*.html
node render.js | cascivo email lint -

It exits non-zero on a blocked finding — a floor client genuinely cannot do it — and never on a caveat, which is partial support worth knowing about. --data <file> uses a local copy instead of fetching, which is what you want in CI or offline.

The programmatic form is the same check, if you would rather wire it in yourself:

import { CASCIVO_ALLOW, indexFeatures, lint } from '@cascivo/email'

const findings = lint(html, indexFeatures(matrix), { allow: CASCIVO_ALLOW })

See what a specific client sees

simulate() is the other half, and the more interesting one. It takes a render and strips the declarations a named client does not support, so you get the document that client would lay out — which is how you find a layout that only holds together because of a feature Outlook lacks:

import { simulate, SIMULATED_CLIENTS } from '@cascivo/email'

const outlook = SIMULATED_CLIENTS.find((c) => c.label === 'Outlook (Windows)')!
const degraded = simulate(html, indexFeatures(matrix), outlook)

SIMULATED_CLIENTS is the list you can pass. This is what the preview's client switcher does, and what the visual baselines screenshot — a template is captured both as written and as Outlook Windows would reduce it, so a layout that depends on an unsupported feature shows up as a diff rather than in an inbox.

Keep it under the clip threshold

Gmail truncates a message past roughly 102 KB and shows "View entire message". Mobile clips lower — around 75 KB, and around 20 KB on iOS Gmail.

The threshold applies to the encoded message body, not the raw string. stats.encodedBytes is the number to watch; stats.bytes will under-report by up to a third.

const { stats } = renderEmail(<Invite … />, { tier: 'strict' })
if (stats.clipRisk !== 'ok') throw new Error(`email is ${stats.encodedBytes} bytes`)

When something is too big, ask where the bytes went:

import { analyze, formatAnalysis } from '@cascivo/email'
console.log(formatAnalysis(analyze(html)))

It splits the total into inline CSS, markup, text and overhead, ranks tags by cost, and lists the most repeated declarations — which is usually where the answer is.

Theming

The theme is a render-time argument, not a runtime one. Custom properties do not work in Gmail or Outlook Windows, so data-theme switching is impossible; the palette is resolved to literal hex before the email leaves your server.

renderEmail(<Invite … />, { theme: 'corporate' })

The font stack is probably your largest repeated cost

Every text primitive restates font-family inline. Outlook Windows renders through Word, which does not carry an inherited font-family into table content — and every layout primitive here is a table, so an element that states no face renders in Times New Roman. The repetition is load-bearing.

What you can change is the length of the stack. --cascivo-email-font-sans, -serif and -mono are read by fontStack() and apply everywhere:

renderEmail(<Invite … />, {
  theme: { ...PALETTES.light, '--cascivo-email-font-sans': 'Arial, Helvetica, sans-serif' },
})

On a large newsletter that is the difference between clipping and not — one reported port spent 15 KB, 20% of the message, on 162 copies of the default stack.

They are keys on the palette object rather than CSS custom properties: no email client supports those, so a declaration in a stylesheet would be read by nobody and cost every adopter bytes. They are also separate from the browser-facing --cascivo-font-* on purpose — those begin ui-sans-serif, system-ui, which resolve to nothing in Outlook Windows.

Dark mode in an inbox is client-controlled. Gmail and Outlook.com apply their own inversion by heuristics nobody outside them has. The preview's "Forced dark" toggle approximates it and says so; treat it as "roughly this much changes", not as a faithful rendering.

Coming from React Email

Most of the surface maps one to one.

import { formatMigration, planMigration } from '@cascivo/email'
console.log(formatMigration(planMigration(['Html', 'Button', 'Tailwind', 'Font'])))

The gaps are deliberate: no Font (web fonts do not load in Gmail or Outlook — use EMAIL_FONTS), no Tailwind (cascivo emits resolved tokens directly, so there is no class-to-inline-style step to perform).

Markdown does have an equivalent — see Prose you do not have at build time — though it renders an allowlisted node set rather than arbitrary Markdown, so check the table there before you assume a port is one-to-one.

What you are trading away

No paid rendering service is used, and none is needed for most of the surface: the conformance lint, the structural invariants, the client simulation and the visual baselines run offline and deterministically.

The residual is Outlook Windows pixel fidelity — the Word engine is a proprietary Windows binary with no emulator, so nothing offline can reproduce it. That is bought down by construction rather than by a test: the primitive set cannot emit what Outlook lacks, and the lint enforces it. A release-time manual pass on a Windows machine covers the rest.

docs/specs/email-target.md §4.2 and §5 carry the full account, including what this does not cover.

As Markdown: /docs/recipe-email.md

← All guides