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, andcolor-schememeta 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:devThis 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
-
Write the template in
src/emails/templates/, for examplePasswordReset.tsx. Wrap the body inEmailLayout, pass apreview, and take the dynamic values as typed props. Use React Email components (Text,Link,Button,Section) and the constants intheme.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; -
Give it sample props with
PreviewPropsand a default export, as above, so it shows up inyarn email:dev. Add a second preview-only file next to it if a variant needs separate props (seeVisitorResumeNoName.tsx). -
Register it in
src/emails/index.ts: one entry inemailTemplates, keyed by the email type, with the template, asubjectfunction and apreheaderfunction.defineEmailinfers 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, }), -
Test it in a
*.test.tsxnext to the template, asVisitorResume.test.tsxdoes: render withrenderEmailand assert onhtmlandtext, including that visitor-controlled strings are escaped and that the plain text has no markup.yarn testruns it. -
Send it by looking the type up in the registry and rendering it.
handleResumeRequestinsrc/lib/resume-request/handle.tsis 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))), });renderEmailreturns{ html, text }, which are the fields the Resend payload (EmailMessageinsrc/lib/resume-request/emails.ts) expects. -
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
divlayouts. - 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
imgso 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
logointheme.ts). - A preheader, via the layout’s
previewprop, andlangon the document (the layout sets it). - A plain-text alternative, always.
renderEmailproduces 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-schemethrough theem-*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 anhrefbuilt from visitor input (like amailto:), 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
ownerNotificationSubjectdoes, 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.