Skip to Content
Email templates

Email templates

The site sends two transactional emails when someone downloads the resume: a notification to the owner and a copy of the resume to the visitor (only when they left an address). Each email type is one React Email  template, built on a shared branded layout, rendered to HTML and plain text from the same source, previewable locally and covered by tests. Sending goes through Resend.

Adding a new email type is: write one template, register it, add a test.

Folder layout

src/emails/ index.ts registry: email type -> template, subject, preheader render.ts renderEmail(element) -> { html, text } theme.ts brand constants: colors, fonts, layout, logo URLs components/ EmailLayout.tsx shared shell: preheader, header logo, footer templates/ VisitorResume.tsx type "visitor-resume" OwnerNotification.tsx type "owner-notification" *.test.tsx render tests, next to each template public/email/ logo.png, logo-dark.png PNG logos (light and dark ground) src/lib/resume-request/ handle.ts the only sender today: renders and sends emails.ts the EmailMessage type (the Resend payload)

The shared layout

EmailLayout (src/emails/components/EmailLayout.tsx) takes a preview string (the preheader) and the body as children. It supplies everything that must be the same in every email:

  • lang="en", a hidden preheader, and color-scheme meta tags
  • a 600px container that fills the screen on phones, with a 24px side gutter
  • the header logo, a PNG with alt text, plus a pale variant swapped in for dark mode
  • hairline rules and a footer with the site name, tagline and link
  • the dark-mode rules, keyed on class names that start with em-

A template renders only its own body inside the layout. Brand values (hex colors, font stacks, site name and URL, logo URLs) live in src/emails/theme.ts. They mirror the site tokens by hand, because email clients need plain hex values and the email layer cannot import vanilla-extract.

To make text follow the dark-mode rules, give it a class: em-text for body copy, em-heading for headings and strong links, em-muted for secondary copy, em-link for links, em-rule for rules. Keep the light values inline as the default.

Preview

yarn email:dev

This starts the React Email preview server at http://localhost:3030  (the script is email dev --dir src/emails --port 3030). It lists every template that has a default export under src/emails/ and renders it with its PreviewProps, so a template appears in the list as soon as the file exists. The preview can switch between desktop and mobile widths and shows the source of each template.

The two visitor variants, with and without a name, are separate preview entries (VisitorResume and VisitorResumeNoName), because one template has one set of PreviewProps.

Add a new email type

  1. Write the template in src/emails/templates/, for example PasswordReset.tsx. Wrap the body in EmailLayout, pass a preview, and take the dynamic values as typed props. Use React Email components (Text, Link, Button, Section) and the constants in theme.ts.

    import { Text } from "@react-email/components"; import { EmailLayout } from "../components/EmailLayout"; import { colors, fonts } from "../theme"; export const PASSWORD_RESET_SUBJECT = "Reset your password"; export const PASSWORD_RESET_PREHEADER = "Use the link inside to choose a new password."; export type PasswordResetProps = { name?: string }; export function PasswordReset({ name }: PasswordResetProps) { return ( <EmailLayout preview={PASSWORD_RESET_PREHEADER}> <Text className="em-text" style={{ fontFamily: fonts.body, color: colors.text }}> Hi {name ?? "there"}, </Text> </EmailLayout> ); } PasswordReset.PreviewProps = { name: "Jordan Lee" } satisfies PasswordResetProps; export default PasswordReset;
  2. Give it sample props with PreviewProps and a default export, as above, so it shows up in yarn email:dev. Add a second preview-only file next to it if a variant needs separate props (see VisitorResumeNoName.tsx).

  3. Register it in src/emails/index.ts: one entry in emailTemplates, keyed by the email type, with the template, a subject function and a preheader function. defineEmail infers the props type from the template, so the compiler checks that the subject and preheader functions accept the same props.

    "password-reset": defineEmail({ Template: PasswordReset, subject: () => PASSWORD_RESET_SUBJECT, preheader: () => PASSWORD_RESET_PREHEADER, }),
  4. Test it in a *.test.tsx next to the template, as VisitorResume.test.tsx does: render with renderEmail and assert on html and text, including that visitor-controlled strings are escaped and that the plain text has no markup. yarn test runs it.

  5. Send it by looking the type up in the registry and rendering it. handleResumeRequest in src/lib/resume-request/handle.ts is the pattern: build the props, then send the rendered output.

    const { Template, subject } = emailTemplates["password-reset"]; const props = { name }; await send({ from, to: [address], subject: subject(props), ...(await renderEmail(createElement(Template, props))), });

    renderEmail returns { html, text }, which are the fields the Resend payload (EmailMessage in src/lib/resume-request/emails.ts) expects.

  6. Preview, then send a real one to yourself before shipping, and look at it on desktop and phone, in light and dark, and with images blocked.

Email-safety rules

These apply to every template. The layout handles most of them; a template must not undo them.

  • Table-based layout from React Email components. Do not use flexbox, grid, or CSS that clients strip. Use the components, not raw div layouts.
  • Inline styles. Many clients drop <style> blocks, so every style that matters is inline. The layout’s <style> block holds only what inline styles cannot express (dark-mode and small-screen rules), and each dark-mode rule has a matching inline light default.
  • About 600px wide, fluid on phones. Side padding stays small enough for a 320px screen.
  • Alt text on every image, and style the img so the alt text reads well when images are blocked (the layout does this for the logo).
  • PNG images only. Gmail does not render SVG. Image URLs must be absolute and public (see logo in theme.ts).
  • A preheader, via the layout’s preview prop, and lang on the document (the layout sets it).
  • A plain-text alternative, always. renderEmail produces it from the same template, so the two cannot drift. Check it reads well: links print their text only, and headings keep their case.
  • Dark mode. Support clients that honor prefers-color-scheme through the em-* classes, and keep every color pair above 4.5:1 contrast so clients that invert colors on their own still read fine.
  • Images blocked. The email must still make sense and every action must still work with no images loaded.
  • Escape visitor-controlled strings. React escapes text children, so pass the value as a child or prop and never build HTML strings or use dangerouslySetInnerHTML. For an href built from visitor input (like a mailto:), encode it so the visitor cannot add parameters.
  • Keep visitor-controlled content out of subjects, beyond what already exists. Strip CR and LF and cap the length, as ownerNotificationSubject does, to prevent header injection.
  • No secrets in templates, props, previews or this documentation.
  • Transactional only. There is no newsletter or unsubscribe flow, and none is expected.
Last updated on