Transactional email
Configure Resend, preview email templates, and keep local development zero-key.
Transactional email
React Email source templates, deterministic rendered HTML, a typed server renderer, and Resend form one transactional-email path. Authentication can be developed locally without a provider account; production cannot.
Modes
| Mode | RESEND_API_KEY | Behavior |
|---|---|---|
| Local/test | unset | no provider call; recipient, template, subject, and props/action URL are logged |
| Provider test | set to test key | rendered HTML sent through Resend; provider errors fail the calling operation |
| Production | required | startup environment validation fails when absent |
The local fallback is intentional for a single trusted developer machine. It is not a fake inbox: auth URLs appear in structured logs and are sensitive bearer links.
Source and generated artifacts
The editable source lives in packages/email/src. The deploy command exports, verifies, and copies
HTML into apps/server/email-templates:
vp run --filter @app/email dev
vp run --filter @app/email deployNever edit apps/server/email-templates/*.html manually. The server requires and preloads all nine
files before accepting traffic:
login-email;verify-email;reset-password;welcome-email;billing-subscription-created;billing-payment-successful;billing-payment-failed;billing-subscription-canceled;security-alert.
The source inventory, deploy manifest, typed renderer names/props/subjects, and generated HTML must remain exactly aligned. Missing templates fail deployment or startup instead of failing only when a buyer requests a password reset.
Rendering and delivery
Callers select a typed template and provide its exact props. The server:
- retrieves the preloaded HTML;
- applies conditional blocks;
- HTML-escapes every inserted value;
- resolves the static or typed subject;
- logs locally or sends
from, recipients, subject, HTML, and optional reply-to through Resend.
Set:
RESEND_API_KEY=<API runtime secret>
EMAIL_FROM=Product <noreply@mail.example.com>EMAIL_FROM must use a sender/domain verified in the same Resend account as the key. Keep the key
only in the API environment; never expose it through VITE_*.
The compiled server still reads external rendered templates. The included Docker image copies them
to its detected layout. Use EMAIL_TEMPLATES_DIR only for a proven custom filesystem layout; an
explicit missing directory is a fatal startup error.
Delivery semantics
Provider rejection is returned as a stable server error to an awaited calling flow. Better Auth email calls are awaited, so signup/reset/magic-link failure remains visible to that auth operation.
Billing lifecycle notifications run after the financial transaction commits. A delivery failure is logged and cannot roll back billing state or force a webhook retry.
The v1 mailer does not include:
- a durable outbox;
- scheduled retries or dead letters;
- delivery/bounce/complaint webhook processing;
- application-owned send receipts;
- provider idempotency keys.
Resend supports 24-hour idempotency keys, but adding a header alone would not fix a process crash between database commit and send. Add a product-owned outbox/state transition when an email is a business-critical deliverable rather than claiming exactly-once delivery.
References: Resend send API, idempotency keys, and errors.
Security and privacy
- Production must never use the log fallback.
- Local logs can contain verification, reset, and magic-link URLs; do not forward them to Sentry, analytics, shared chat, or public CI.
- Sentry console breadcrumbs are disabled for this reason.
- Do not log provider keys, complete provider responses, cookies, or unrelated template data.
- Keep email props minimal; transactional email is not an analytics warehouse.
- Product-specific unsubscribe, retention, consent, and marketing rules are buyer responsibilities.
- A rendered link must use the exact trusted application origin and HTTPS in production.
Verification
For every template change:
vp run --filter @app/email deploy
bun test --cwd apps/server src/infra/email/renderer.test.ts src/infra/mailer/client.test.ts
vp run --filter @app/server build
git diff --checkThen send through a verified test domain and inspect subject, sender, links, mobile layout, dark mode, spam result, and provider error/log behavior. A source preview alone is not production proof.
Consult Add a transactional email and Deploy a fresh application.
Failure guide
| Failure | Inspect |
|---|---|
| Local message “missing” | structured server log and correct local fallback mode |
| Resend sender rejected | verified domain, EMAIL_FROM, key/account/environment |
| Link points to localhost/wrong host | FRONTEND_URL, SERVER_URL, Better Auth trusted origins |
| Placeholder remains in message | source/deploy/renderer inventory and the deploy command above |
| Server exits before ready | rendered directory, all nine files, EMAIL_TEMPLATES_DIR |
| Billing state correct but email absent | post-commit notification log; do not replay the financial event |
| Duplicate critical message | no v1 outbox/idempotency contract; design domain-owned delivery state |