email — mail the brand, not defaults
Stage: Phase 7 — Backend - Reads: design/BRIEF.md, design/SYSTEM.md - Writes: emails/.tsx, emails/theme.ts, lib/email.ts, send calls in app/actions/
Standard
- The inbox is a brand surface. Every template carries SYSTEM.md's palette, type hierarchy, and voice — a first-grade site sending a default-gray email breaks trust at the exact moment it's being decided.
- Resend 6 returns
{ data, error } and does NOT throw on API errors. Every send checks error explicitly; an unchecked send is silent mail loss.
- The React Email component goes in the
react property of emails.send — never a hand-rolled HTML string.
- Every template is previewed in the react-email dev server before it's wired to a send.
- Email CSS is 2009-grade: style props only, table-safe components from
@react-email/components, ≤600px container, hex colors (no oklch), system-stack font fallbacks. The design must hold when Gmail strips the web font.
- Transactional only: contact notifications, magic links, receipts, welcomes. No marketing machinery unless BRIEF.md demands it.
- When the brief includes a newsletter, its signup is double opt-in wherever the audience is in the DACH region: single opt-in (subscribing on submit) is unlawful advertising under German UWG §7, so the address joins the list only after a confirmation link is clicked. A form that's legal in the US can be illegal here — check the jurisdiction, not the UX convention.
Process
- Read design/BRIEF.md: which flows send mail? Contact form → notification to the team (and optionally a confirmation to the sender). Newsletter → double-opt-in confirmation (the footer's newsletter row wires to this, never a fire-and-subscribe action — see below). Auth → magic-link/verification templates for ultraweb:auth to call. Commerce → receipt. No flows → skip this skill entirely.
npm i resend @react-email/components and npm i -D react-email. Create emails/ at the project root.
- Write
emails/theme.ts: SYSTEM.md tokens translated to email-safe values (oklch → hex, display font + system fallback stack).
- Build one template per flow. Preview:
npx react-email dev --dir emails --port 3001 (3000 belongs to next dev). Iterate until it reads as this site's brand, not React Email's starter.
- Write the lazy send helper; wire it into the server action per ultraweb:server-actions — failures return as state, never throw at the user.
- Env:
RESEND_API_KEY in .env.local and listed in .env.example. Dev sends use onboarding@resend.dev (delivers only to the account owner's address until a domain is verified); production requires a verified sending domain — prefer a subdomain (mail.acme.com) to isolate reputation.
The send call
// lib/email.ts
import { Resend } from 'resend'
let client: Resend | null = null
export function getResend(): Resend {
if (!client) client = new Resend(process.env.RESEND_API_KEY) // lazy: builds pass without the key
return client
}
// inside app/actions/contact.ts, after zod validation (state shape per ultraweb:server-actions)
import { getResend } from '@/lib/email'
import ContactNotification from '@/emails/contact-notification'
const { data, error } = await getResend().emails.send({
from: 'Acme <contact@mail.acme.com>',
to: 'team@acme.com',
replyTo: parsed.data.email, // team replies land with the sender
subject: `${parsed.data.name} — new inquiry`,
react: ContactNotification(parsed.data), // component in the react property; called as a fn so the action stays .ts
})
if (error) return { ok: false, errors: { form: ['Message not sent — try again or email us directly.'] } }
Templates
// emails/contact-notification.tsx
import { Html, Head, Preview, Body, Container, Text, Hr, Link } from '@react-email/components'
import { t } from './theme'
export default function ContactNotification({ name, email, message }: { name: string; email: string; message: string }) {
return (
<Html>
<Head />
<Preview>{`${name}: ${message.slice(0, 80)}`}</Preview>
<Body style={{ margin: 0, backgroundColor: t.bg, fontFamily: t.font, color: t.fg }}>
<Container style={{ maxWidth: 560, padding: '40px 24px' }}>
<Text style={{ fontSize: 20, fontWeight: 700, letterSpacing: '-0.02em', margin: '0 0 24px' }}>New inquiry</Text>
<Text style={{ fontSize: 15, lineHeight: '24px', whiteSpace: 'pre-wrap', margin: 0 }}>{message}</Text>
<Hr style={{ borderColor: t.border, margin: '32px 0' }} />
<Text style={{ fontSize: 13, color: t.muted, margin: 0 }}>
{name} · <Link href={`mailto:${email}`} style={{ color: t.accent }}>{email}</Link>
</Text>
</Container>
</Body>
</Html>
)
}
// emails/theme.ts — SYSTEM.md translated for email clients: email CSS can't parse oklch
// and never sees globals.css. Read the hexes from lib/tokens.ts (ultraweb:tokens) — don't re-eyeball them.
export const t = {
bg: '#faf9f7', fg: '#1c1917', muted: '#78716c', border: '#e7e5e4', accent: '#0d7a68',
font: "'Söhne', -apple-system, 'Segoe UI', Helvetica, sans-serif", // display face + system stack
}
Rules:
- Hierarchy in miniature: one heading, one body block, one accent use — the restraint taste demands of a page, at 560px.
<Preview> is designed copy (the inbox's second line) — write it in the brief's voice, never let it default to the first template string.
- CTA buttons:
<Button> from @react-email/components, solid accent background, ≥44px tall via padding — never a bare link for the primary action.
- Web fonts go through the
<Font> component in <Head> with an explicit fallback family — Gmail and Outlook render the fallback, so check the preview in the system stack too. Exact props: verify against current docs first.
- Dark mode in email clients is forced and unreliable: keep bg/fg away from pure
#fff/#000 so auto-inversion doesn't produce mud.
Can I Email — the hard floor
Check any technique against caniemail.com before shipping it. These four fail often enough to be standing rules rather than lookups — Outlook's Word rendering engine still carries a large share of DACH business inboxes.
- Tables, not modern layout.
display: flex, display: grid, and position are ignored outright. Structure with <Section>/<Row>/<Column> from @react-email/components; a flex row that "looks fine in Gmail" is a collapsed email elsewhere.
- Inline style props only.
<style> blocks and classes are stripped or ignored per client, so the style attribute on the element is the only reliable delivery. Design one 560–600px column that works unchanged; treat any @media rule as an enhancement, never the structure.
- Resolved hex, never a custom property.
var(--accent) resolves to nothing in an inbox. ultraweb:tokens exports lib/tokens.ts with the palette already flattened to sRGB hex for exactly this case (and for OG images) — emails/theme.ts reads those values so a token change propagates instead of drifting into a stale hand-copy.
- No SVG, no background-image, no web-font dependency. Outlook drops all three: ship logos as PNG with explicit
width/height, paint color with a table cell's backgroundColor, and let the <Font> fallback stack carry the type.
Double opt-in — the newsletter confirmation
German UWG §7 (settled across OLG rulings) treats single opt-in — putting an address on the list the moment a form submits — as unlawful advertising: consent has to be proven, not assumed. So a DACH newsletter form never subscribes. Submit writes a pending (unconfirmed) record carrying a signed, single-use token that expires (~48h) — schema per ultraweb:database — and sends a Bestätigungs-Mail; the address becomes a real subscriber only when the confirmation link is clicked, which the confirm route verifies and flips per ultraweb:server-actions. The footer's newsletter row wires to this action, not a fire-and-subscribe one.
// app/actions/newsletter.ts — double opt-in: submit sends a confirmation link, it does NOT subscribe
import { getResend } from '@/lib/email'
import NewsletterConfirm from '@/emails/newsletter-confirm'
import { createPendingSubscriber } from '@/lib/newsletter' // writes status:'pending' + signed token, per ultraweb:database
// inside the action, after zod validation:
const { token } = await createPendingSubscriber(parsed.data.email)
const confirmUrl = `${process.env.NEXT_PUBLIC_SITE_URL}/newsletter/confirm?token=${token}` // absolute: an email has no origin
const { data, error } = await getResend().emails.send({
from: 'Acme <mail@mail.acme.com>',
to: parsed.data.email,
subject: 'Bitte bestätige deine Anmeldung',
react: NewsletterConfirm({ confirmUrl }),
})
if (error) return { ok: false, errors: { form: ['Bestätigung konnte nicht gesendet werden — bitte erneut versuchen.'] } }
// success state: "Check your inbox and click the link" — never "You're subscribed"
// emails/newsletter-confirm.tsx — the Bestätigungs-Mail; the address joins the list only when this is clicked
import { Html, Head, Preview, Body, Container, Text, Button } from '@react-email/components'
import { t } from './theme'
export default function NewsletterConfirm({ confirmUrl }: { confirmUrl: string }) {
return (
<Html>
<Head />
<Preview>Ein Klick bestätigt deine Anmeldung — sonst passiert nichts.</Preview>
<Body style={{ margin: 0, backgroundColor: t.bg, fontFamily: t.font, color: t.fg }}>
<Container style={{ maxWidth: 560, padding: '40px 24px' }}>
<Text style={{ fontSize: 20, fontWeight: 700, letterSpacing: '-0.02em', margin: '0 0 16px' }}>Fast geschafft</Text>
<Text style={{ fontSize: 15, lineHeight: '24px', margin: '0 0 24px' }}>
Bestätige mit einem Klick, dass du unseren Newsletter abonnieren möchtest. Ohne diese Bestätigung schicken wir dir nichts.
</Text>
<Button href={confirmUrl} style={{ backgroundColor: t.accent, color: '#fff', fontSize: 15, fontWeight: 600, padding: '14px 28px', borderRadius: 8, textDecoration: 'none' }}>
Anmeldung bestätigen
</Button>
<Text style={{ fontSize: 13, color: t.muted, margin: '24px 0 0' }}>
Nicht angefordert? Ignoriere diese Mail — ohne Klick landest du auf keiner Liste.
</Text>
</Container>
</Body>
</Html>
)
}
Anti-patterns
try { around emails.send with no error check — Resend 6 does not throw on API errors; the catch never fires and mail vanishes silently.
html: with a template-literal string when a React template exists — the react property renders the component; hand-rolled HTML drifts off-brand.
oklch( anywhere under emails/ — email clients can't parse it; theme.ts holds the hex translations.
className= in a template without react-email's Tailwind wrapper — dead classes; and that wrapper takes a v3-style config that ignores the site's v4 @theme (verify against current docs first) — style props + theme.ts are the reliable path.
import '@/app/globals.css' or next/font inside emails/ — site CSS never reaches an inbox.
NEXT_PUBLIC_RESEND — the key is server-only; a public prefix ships it in the client bundle.
new Resend( at module scope of anything a page imports — build fails on machines without the key; lazy-init in the helper.
from: 'onboarding@resend.dev' reaching production — dev-only sender; swap to the verified domain before ship.
- Adding the address to the list on submit (single opt-in) for a DACH audience — unlawful advertising under German UWG §7. On submit you write a pending record and send the Bestätigungs-Mail; the subscriber is real only after the confirmation link is clicked.
- An unsigned or reusable confirmation token — a guessable or shared link lets a scraper confirm addresses that aren't theirs. Sign it, scope it to the one address, and let the pending record expire (~48h) so it can't be confirmed weeks later.
- A newsletter success state that says "Subscribed!" when only the pending record exists — it isn't true yet; say "Check your inbox and click the link to confirm."
- ✨/🚀 in subject lines — taste bans emoji in production copy, and subject lines are production copy.
Worked example — Kaffeewerk Ost, order confirmation after Stripe checkout
Moved to references/example.md — read only when this build's case is genuinely ambiguous; the sections above are the decision material.
Composes with
Moved to references/composes.md — the handoff map; load it when orchestrating this skill against its neighbors.
1---2name: email3description: Transactional email for a Next.js 16 site — Resend 6 (the { data, error } return; it does NOT throw on API errors, check error explicitly) with @react-email/components templates that carry the design system's palette and type into the inbox, the React component passed via the react property, a contact-form flow wired through a server action, the react-email dev preview server, and RESEND_API_KEY handling with a lazy client so builds pass without the key. Invoke during the backend phase whenever the brief needs outbound mail — contact-form notifications, double-opt-in newsletter confirmations, magic links, welcome or receipt emails — when a template looks default or off-brand, or when sends fail silently. Trigger phrases — "contact form email", "send an email", "transactional email", "email template", "Resend", "magic link email", "double opt-in confirmation", "newsletter Bestätigungs-Mail", "the form submits but no email arrives".4---56# email — mail the brand, not defaults78**Stage:** Phase 7 — Backend - **Reads:** design/BRIEF.md, design/SYSTEM.md - **Writes:** emails/*.tsx, emails/theme.ts, lib/email.ts, send calls in app/actions/*910## Standard1112- The inbox is a brand surface. Every template carries SYSTEM.md's palette, type hierarchy, and voice — a first-grade site sending a default-gray email breaks trust at the exact moment it's being decided.13- Resend 6 returns `{ data, error }` and does NOT throw on API errors. Every send checks `error` explicitly; an unchecked send is silent mail loss.14- The React Email component goes in the `react` property of `emails.send` — never a hand-rolled HTML string.15- Every template is previewed in the react-email dev server before it's wired to a send.16- Email CSS is 2009-grade: style props only, table-safe components from `@react-email/components`, ≤600px container, hex colors (no oklch), system-stack font fallbacks. The design must hold when Gmail strips the web font.17- Transactional only: contact notifications, magic links, receipts, welcomes. No marketing machinery unless BRIEF.md demands it.18- When the brief includes a newsletter, its signup is double opt-in wherever the audience is in the DACH region: single opt-in (subscribing on submit) is unlawful advertising under German UWG §7, so the address joins the list only after a confirmation link is clicked. A form that's legal in the US can be illegal here — check the jurisdiction, not the UX convention.1920## Process21221. Read design/BRIEF.md: which flows send mail? Contact form → notification to the team (and optionally a confirmation to the sender). Newsletter → double-opt-in confirmation (the footer's newsletter row wires to this, never a fire-and-subscribe action — see below). Auth → magic-link/verification templates for ultraweb:auth to call. Commerce → receipt. No flows → skip this skill entirely.232. `npm i resend @react-email/components` and `npm i -D react-email`. Create `emails/` at the project root.243. Write `emails/theme.ts`: SYSTEM.md tokens translated to email-safe values (oklch → hex, display font + system fallback stack).254. Build one template per flow. Preview: `npx react-email dev --dir emails --port 3001` (3000 belongs to `next dev`). Iterate until it reads as this site's brand, not React Email's starter.265. Write the lazy send helper; wire it into the server action per ultraweb:server-actions — failures return as state, never throw at the user.276. Env: `RESEND_API_KEY` in .env.local and listed in .env.example. Dev sends use `onboarding@resend.dev` (delivers only to the account owner's address until a domain is verified); production requires a verified sending domain — prefer a subdomain (`mail.acme.com`) to isolate reputation.2829## The send call3031```ts32// lib/email.ts33import { Resend } from 'resend'3435let client: Resend | null = null36export function getResend(): Resend {37 if (!client) client = new Resend(process.env.RESEND_API_KEY) // lazy: builds pass without the key38 return client39}40```4142```ts43// inside app/actions/contact.ts, after zod validation (state shape per ultraweb:server-actions)44import { getResend } from '@/lib/email'45import ContactNotification from '@/emails/contact-notification'4647const { data, error } = await getResend().emails.send({48 from: 'Acme <contact@mail.acme.com>',49 to: 'team@acme.com',50 replyTo: parsed.data.email, // team replies land with the sender51 subject: `${parsed.data.name} — new inquiry`,52 react: ContactNotification(parsed.data), // component in the react property; called as a fn so the action stays .ts53})54if (error) return { ok: false, errors: { form: ['Message not sent — try again or email us directly.'] } }55```5657## Templates5859```tsx60// emails/contact-notification.tsx61import { Html, Head, Preview, Body, Container, Text, Hr, Link } from '@react-email/components'62import { t } from './theme'6364export default function ContactNotification({ name, email, message }: { name: string; email: string; message: string }) {65 return (66 <Html>67 <Head />68 <Preview>{`${name}: ${message.slice(0, 80)}`}</Preview>69 <Body style={{ margin: 0, backgroundColor: t.bg, fontFamily: t.font, color: t.fg }}>70 <Container style={{ maxWidth: 560, padding: '40px 24px' }}>71 <Text style={{ fontSize: 20, fontWeight: 700, letterSpacing: '-0.02em', margin: '0 0 24px' }}>New inquiry</Text>72 <Text style={{ fontSize: 15, lineHeight: '24px', whiteSpace: 'pre-wrap', margin: 0 }}>{message}</Text>73 <Hr style={{ borderColor: t.border, margin: '32px 0' }} />74 <Text style={{ fontSize: 13, color: t.muted, margin: 0 }}>75 {name} · <Link href={`mailto:${email}`} style={{ color: t.accent }}>{email}</Link>76 </Text>77 </Container>78 </Body>79 </Html>80 )81}82```8384```ts85// emails/theme.ts — SYSTEM.md translated for email clients: email CSS can't parse oklch86// and never sees globals.css. Read the hexes from lib/tokens.ts (ultraweb:tokens) — don't re-eyeball them.87export const t = {88 bg: '#faf9f7', fg: '#1c1917', muted: '#78716c', border: '#e7e5e4', accent: '#0d7a68',89 font: "'Söhne', -apple-system, 'Segoe UI', Helvetica, sans-serif", // display face + system stack90}91```9293Rules:9495- Hierarchy in miniature: one heading, one body block, one accent use — the restraint taste demands of a page, at 560px.96- `<Preview>` is designed copy (the inbox's second line) — write it in the brief's voice, never let it default to the first template string.97- CTA buttons: `<Button>` from `@react-email/components`, solid accent background, ≥44px tall via padding — never a bare link for the primary action.98- Web fonts go through the `<Font>` component in `<Head>` with an explicit fallback family — Gmail and Outlook render the fallback, so check the preview in the system stack too. Exact props: verify against current docs first.99- Dark mode in email clients is forced and unreliable: keep bg/fg away from pure `#fff`/`#000` so auto-inversion doesn't produce mud.100101## Can I Email — the hard floor102103Check any technique against caniemail.com before shipping it. These four fail often enough to be standing rules rather than lookups — Outlook's Word rendering engine still carries a large share of DACH business inboxes.104105- **Tables, not modern layout.** `display: flex`, `display: grid`, and `position` are ignored outright. Structure with `<Section>`/`<Row>`/`<Column>` from `@react-email/components`; a flex row that "looks fine in Gmail" is a collapsed email elsewhere.106- **Inline style props only.** `<style>` blocks and classes are stripped or ignored per client, so the style attribute on the element is the only reliable delivery. Design one 560–600px column that works unchanged; treat any `@media` rule as an enhancement, never the structure.107- **Resolved hex, never a custom property.** `var(--accent)` resolves to nothing in an inbox. `ultraweb:tokens` exports `lib/tokens.ts` with the palette already flattened to sRGB hex for exactly this case (and for OG images) — `emails/theme.ts` reads those values so a token change propagates instead of drifting into a stale hand-copy.108- **No SVG, no background-image, no web-font dependency.** Outlook drops all three: ship logos as PNG with explicit `width`/`height`, paint color with a table cell's `backgroundColor`, and let the `<Font>` fallback stack carry the type.109110## Double opt-in — the newsletter confirmation111112German UWG §7 (settled across OLG rulings) treats single opt-in — putting an address on the list the moment a form submits — as unlawful advertising: consent has to be *proven*, not assumed. So a DACH newsletter form never subscribes. Submit writes a **pending** (unconfirmed) record carrying a signed, single-use token that expires (~48h) — schema per ultraweb:database — and sends a Bestätigungs-Mail; the address becomes a real subscriber only when the confirmation link is clicked, which the confirm route verifies and flips per ultraweb:server-actions. The footer's newsletter row wires to this action, not a fire-and-subscribe one.113114```ts115// app/actions/newsletter.ts — double opt-in: submit sends a confirmation link, it does NOT subscribe116import { getResend } from '@/lib/email'117import NewsletterConfirm from '@/emails/newsletter-confirm'118import { createPendingSubscriber } from '@/lib/newsletter' // writes status:'pending' + signed token, per ultraweb:database119120// inside the action, after zod validation:121const { token } = await createPendingSubscriber(parsed.data.email)122const confirmUrl = `${process.env.NEXT_PUBLIC_SITE_URL}/newsletter/confirm?token=${token}` // absolute: an email has no origin123const { data, error } = await getResend().emails.send({124 from: 'Acme <mail@mail.acme.com>',125 to: parsed.data.email,126 subject: 'Bitte bestätige deine Anmeldung',127 react: NewsletterConfirm({ confirmUrl }),128})129if (error) return { ok: false, errors: { form: ['Bestätigung konnte nicht gesendet werden — bitte erneut versuchen.'] } }130// success state: "Check your inbox and click the link" — never "You're subscribed"131```132133```tsx134// emails/newsletter-confirm.tsx — the Bestätigungs-Mail; the address joins the list only when this is clicked135import { Html, Head, Preview, Body, Container, Text, Button } from '@react-email/components'136import { t } from './theme'137138export default function NewsletterConfirm({ confirmUrl }: { confirmUrl: string }) {139 return (140 <Html>141 <Head />142 <Preview>Ein Klick bestätigt deine Anmeldung — sonst passiert nichts.</Preview>143 <Body style={{ margin: 0, backgroundColor: t.bg, fontFamily: t.font, color: t.fg }}>144 <Container style={{ maxWidth: 560, padding: '40px 24px' }}>145 <Text style={{ fontSize: 20, fontWeight: 700, letterSpacing: '-0.02em', margin: '0 0 16px' }}>Fast geschafft</Text>146 <Text style={{ fontSize: 15, lineHeight: '24px', margin: '0 0 24px' }}>147 Bestätige mit einem Klick, dass du unseren Newsletter abonnieren möchtest. Ohne diese Bestätigung schicken wir dir nichts.148 </Text>149 <Button href={confirmUrl} style={{ backgroundColor: t.accent, color: '#fff', fontSize: 15, fontWeight: 600, padding: '14px 28px', borderRadius: 8, textDecoration: 'none' }}>150 Anmeldung bestätigen151 </Button>152 <Text style={{ fontSize: 13, color: t.muted, margin: '24px 0 0' }}>153 Nicht angefordert? Ignoriere diese Mail — ohne Klick landest du auf keiner Liste.154 </Text>155 </Container>156 </Body>157 </Html>158 )159}160```161162## Anti-patterns163164- `try {` around `emails.send` with no `error` check — Resend 6 does not throw on API errors; the catch never fires and mail vanishes silently.165- `html:` with a template-literal string when a React template exists — the `react` property renders the component; hand-rolled HTML drifts off-brand.166- `oklch(` anywhere under `emails/` — email clients can't parse it; theme.ts holds the hex translations.167- `className=` in a template without react-email's Tailwind wrapper — dead classes; and that wrapper takes a v3-style config that ignores the site's v4 `@theme` (verify against current docs first) — style props + theme.ts are the reliable path.168- `import '@/app/globals.css'` or `next/font` inside `emails/` — site CSS never reaches an inbox.169- `NEXT_PUBLIC_RESEND` — the key is server-only; a public prefix ships it in the client bundle.170- `new Resend(` at module scope of anything a page imports — build fails on machines without the key; lazy-init in the helper.171- `from: 'onboarding@resend.dev'` reaching production — dev-only sender; swap to the verified domain before ship.172- Adding the address to the list on submit (single opt-in) for a DACH audience — unlawful advertising under German UWG §7. On submit you write a *pending* record and send the Bestätigungs-Mail; the subscriber is real only after the confirmation link is clicked.173- An unsigned or reusable confirmation token — a guessable or shared link lets a scraper confirm addresses that aren't theirs. Sign it, scope it to the one address, and let the pending record expire (~48h) so it can't be confirmed weeks later.174- A newsletter success state that says "Subscribed!" when only the pending record exists — it isn't true yet; say "Check your inbox and click the link to confirm."175- ✨/🚀 in subject lines — taste bans emoji in production copy, and subject lines are production copy.176177## Worked example — Kaffeewerk Ost, order confirmation after Stripe checkout178179Moved to `references/example.md` — read only when this build's case is genuinely ambiguous; the sections above are the decision material.180181## Composes with182183Moved to `references/composes.md` — the handoff map; load it when orchestrating this skill against its neighbors.