Email templates
An email template is one file in your project’s emails/ directory, and the file name is its key. It declares what data it needs, what subject that data produces, and what it renders. The journey that sends it passes exactly those props, typechecked.
The contract
Section titled “The contract”import type { SendClass, Subject } from "@cowliss/cli/emails";import { Html, Text } from "@react-email/components";import { z } from "zod";
export const props = z.object({ orderId: z.string(), firstName: z.string().optional(),});
export const subject: Subject<typeof props> = (p) => `Thanks for order ${p.orderId}`;
export const sendClass: SendClass = "transactional";
export const tags = ["commerce"];
export default function VipOrder(p: z.infer<typeof props>) { return ( <Html> <Text> Hi {p.firstName ?? "there"}, order {p.orderId} is on its way. </Text> </Html> );}| Export | Required | What it is |
|---|---|---|
default |
yes | The React component. It receives the validated props and nothing else. |
props |
yes | A zod object schema. It is the template’s entire input contract. |
subject |
yes | A function of the props, or a plain string when the subject is fixed. |
sendClass |
no | "marketing" (the default) or "transactional". See below. |
verifyLink |
no | true asks the platform to mint a signed verifyUrl prop at send time. |
tags |
no | Your own labels, shown on the release page. Same rules as a journey’s: at most 20, each at most 50 characters, kept as written. |
@cowliss/cli/emails exports types only. You import React Email yourself, so the package never pulls a runtime dependency into your bundle.
Props are typed across two files
Section titled “Props are typed across two files”cow build turns each props schema into JSON Schema in the release manifest, and writes .cow/types.d.ts declaring your project’s template keys and their prop types. That file is what types the send call in the journey:
await api.email.send({ template: "vip-order", // autocompleted from your emails/ directory props: { orderId: event.properties.orderId as string },}); // missing or misspelled props are a type errorAt send time the platform validates the props again, against the schema pinned in the release the execution is running on. So a template whose props you changed last week cannot break a journey that started the week before: it is still sending the old template with the old schema.
There is no implicit merge of the profile’s traits into props. If the mail says “Hi Ada”, the journey reads the profile and passes firstName. That is one more line, and it is the line that tells you what the email actually depends on.
Send classes
Section titled “Send classes”marketing(default): the full gate list applies, including purpose consent and the per-user frequency cap. Use it for anything a recipient could reasonably want to stop receiving.transactional: skips consent and the frequency cap, because a receipt or a password reset is not something anyone opted into. Every other gate still applies: a suppressed address, a paused org, an empty wallet, and an unverified sending domain all still stop the send.
The class is a property of the template, not of the send call, so one template cannot be marketing in one journey and transactional in another. See Sending and delivery for the gate order and the skip reasons.
One-click unsubscribe is not something you put in the body: it rides in the RFC 8058 List-Unsubscribe headers the send path attaches, with a signed token minted per message. Do not hand-roll one.
verifyLink
Section titled “verifyLink”A template with verifyLink = true receives one extra prop the journey does not pass: verifyUrl, a signed, expiring link that marks the recipient’s current address verified when opened. The platform mints it at send time, against the address the message is actually going to, and a resend a day later carries a fresh token rather than the first one. That is why it cannot come from your code: journey code cannot sign, and must not hold a token that outlives its send.
Declare it optional, and read it as present. The same props schema types the journey’s send call, so a required verifyUrl would oblige the journey to pass the one prop it is not allowed to know:
export const verifyLink = true;export const props = z.object({ firstName: z.string().optional(), // Supplied by the platform at send time; the journey never passes it. verifyUrl: z.url().optional(),});Sharing layout between templates
Section titled “Sharing layout between templates”A file whose name starts with _ is a shared module, not a template. It is skipped by discovery and imported by relative path:
import { Body, Container, Head, Html } from "@react-email/components";import type { ReactNode } from "react";
export function Shell({ children }: { children: ReactNode }) { return ( <Html> <Head /> <Body style={{ backgroundColor: "#f6f6f6" }}> <Container style={{ maxWidth: 480, padding: 24 }}>{children}</Container> </Body> </Html> );}import { Shell } from "./_shell";What renders where, and when
Section titled “What renders where, and when”Rendering happens on the platform, inside the same sandbox journeys run in, at send time. It produces { subject, html, text }: the text part is derived from the HTML, so the two never drift, with images and elements marked data-skip-in-text="true" left out.
Two consequences worth knowing:
- Inline styles only. Email clients strip Tailwind, external stylesheets, and
<style>blocks alike. React Email’s components exist because of this. - Your component runs in a sandbox with no I/O. No
fetch, no filesystem, no clock you can trust. Everything a template needs arrives in its props.
In the development environment the render happens exactly as in production and then the message stops: it is stored as a captured delivery with the rendered subject, HTML, and text, which the dashboard shows in full. That is how you read the actual mail, and click the actual link in it, without sending anything. See Testing journeys.
Limits
Section titled “Limits”A render gets 5 seconds, 64 MB of memory, 128 KB of props, and must produce at most 512 KB of rendered output. A template that exceeds one fails the send with a typed error on the execution rather than sending something truncated.