SsuperslateDocs
Build with the boilerplate

Add transactional email

Add a typed transactional message from event through rendered template and provider delivery.

Add a transactional email

When to use

Use this path for a product-triggered message whose recipient, event, and action are known: account verification, authentication, security, billing, or a similarly bounded lifecycle event. Do not use it for campaigns, newsletters, or a generic notification blast.

This runbook makes a template and delivery call production-capable. It does not promise durable outbox semantics. If losing a non-auth email after a process crash would violate the product contract, add a database-backed delivery claim and retry policy as explicit domain work.

Files and boundaries

  • packages/email/src/<template>.tsx: React Email source and preview defaults.
  • packages/email/src/index.ts: source export.
  • packages/email/scripts/deploy.ts: exact exported-file inventory and link placeholder.
  • apps/server/email-templates/: generated HTML; never edit it manually.
  • apps/server/src/infra/email/renderer.ts: typed template name, props, subject, preload, escaping.
  • apps/server/src/infra/mailer/client.ts: Resend delivery and zero-key local fallback.
  • apps/server/src/domains/<domain>/: decides when, why, and to whom the email is sent.
  • apps/server/src/config/env.ts: provider configuration contract.

The email package owns presentation. The renderer owns the runtime template contract. A domain owns the sending decision. Provider details must not leak into domain code.

Procedure

  1. Define the event, recipient, deduplication behavior, and whether delivery failure must fail the initiating request. Authentication and security flows should await required delivery. Best-effort product notifications may log and continue only when that is a deliberate product decision.

  2. Create packages/email/src/<template>.tsx with placeholder defaults such as {{actionLink}}. Do not hard-code the product name, support address, production host, or a real token.

  3. Export the component from packages/email/src/index.ts.

  4. Add <template>.html and its required action-link placeholder to templateLinkMap in the deploy script. The deploy command rejects missing and unexpected exports.

  5. Add the template name, exact props type, and subject to the renderer. Keep props minimal and serializable. Runtime placeholder values are HTML-escaped.

  6. Regenerate buyer HTML through the owning command:

    vp run --filter @app/email deploy
  7. Call mailer().send(...) from the owning domain with the typed template and props. Send only after the database state that justifies the email has committed; never send an irreversible provider side effect inside a transaction that can roll back.

  8. Add tests for the domain decision, rendered placeholders, the zero-key log path, and provider rejection. If the event can replay, prove its email policy under replay.

  9. Confirm production has a verified sending domain, an aligned EMAIL_FROM, and a non-empty RESEND_API_KEY. Production environment parsing fails without the provider key.

Verification

Run:

vp run --filter @app/email deploy
bun test --cwd apps/server src/infra/email/renderer.test.ts src/infra/mailer/client.test.ts
vp check
vp run --filter @app/server typecheck
vp run --filter @app/server build

Then start the server without RESEND_API_KEY, trigger the real event, and verify the structured server log contains the expected local action link and no provider request occurs. With a Resend test configuration, send to an owned inbox and inspect sender, subject, action URL, mobile layout, and delivery result.

Security constraints

  • Treat recipient addresses, names, amounts, plan labels, and URLs as untrusted input. Use typed placeholders; do not concatenate raw HTML.
  • Never log RESEND_API_KEY, OAuth secrets, passwords, or provider payloads. Auth links are logged only by the intentional zero-key local fallback and must not be enabled in production.
  • Generate action URLs through the owning auth or domain flow. Do not invent reusable tokens in the template layer.
  • Keep reset, verification, and magic-link expiry and one-use behavior in Better Auth.
  • Do not accept a recipient or template name directly from an unauthenticated request.
  • Avoid sensitive personal data in subjects, because subjects can appear in notifications and logs.
  • Verify the sending domain and use a product-controlled From address; do not spoof user input.

Failure modes

  • Email template ... not loaded: startup did not preload templates or the typed name drifted.
  • Required email template is missing: regenerate HTML and inspect the inventory failure.
  • Placeholder remains in delivered HTML: the renderer props contract and source placeholder differ.
  • Local event produces no link: the owning domain did not await/call the mailer, or its mapping returned no email.
  • Resend rejects the message: inspect the stable provider error, sender verification, recipient restrictions, and provider status; do not report the event as delivered.
  • A database rollback occurs after delivery: the side effect was sent inside the wrong transaction.
  • Duplicate email on webhook replay: the domain lacks an idempotent event/delivery policy.

Rollback and diagnosis

Remove the domain call first to stop new sends. Revert the typed renderer entry, source export, deploy-map entry, and source component together, then rerun the deploy command so generated HTML matches the source inventory. Do not hand-delete or patch a generated HTML file.

For delivery incidents, preserve the product event identifier, template name, recipient hash or approved address, provider request identifier, and failure category. Never paste secrets or live auth tokens into an issue.

Acceptance criteria

  • The source, export map, renderer inventory, generated HTML, and typed props agree.
  • Startup preloads every required template and fails closed when one is missing.
  • Untrusted placeholder values are escaped and rendered output has no unresolved required token.
  • Zero-key development logs the actionable local link and never calls Resend.
  • Configured delivery sends the expected typed payload and surfaces provider rejection.
  • The owning domain has an explicit replay and failure policy.
  • Production configuration requires the provider key and uses a verified sender.
  • The focused tests, vp check, server typecheck, and server build pass.

Agent prohibitions

  • Do not edit apps/server/email-templates/*.html manually.
  • Do not introduce a second mail provider directly inside a product domain.
  • Do not silently swallow a required authentication or security email failure.
  • Do not send before the state authorizing the email commits.
  • Do not log provider credentials or production action tokens.
  • Do not claim durable delivery, retries, or exactly-once email without a persisted delivery claim.
  • Do not add Tailwind to the web application; React Email's isolated styling is separate.

On this page