--- title: Transactional Emails description: Sending transactional email with Resend and React Email. type: reference related: - /en/docs/apps/email - /en/docs/packages/authentication --- # Transactional Emails `@kreogen/email` exports a [Resend](https://resend.com) client and a set of [React Email](https://react.email) templates. Resend is an HTTPS API, so a production deployment has no SMTP server to run and no mail queue to operate. Development is the exception: with no Resend token the auth flow delivers to the Mailpit container instead, over SMTP. ## The client may be undefined ```ts title="packages/email/index.ts" export const resend = RESEND_TOKEN ? new Resend(RESEND_TOKEN) : undefined; ``` Like every optional integration here, it is `undefined` until configured. That is not a detail you can skip past — `resend.emails.send(...)` on an unconfigured deploy is a `TypeError`, and the two correct patterns differ by what the user is doing at the time. **When nobody is waiting, optional-chain it:** ```ts import { resend } from '@kreogen/email'; resend?.emails.send({ from, to, subject, text }); ``` Fine for a digest or a notification that nobody has been promised. **When somebody is waiting, check and fail loudly:** ```ts if (!(resend && env.RESEND_FROM)) { log.error('Contact form submitted but email is not configured'); throw new Error('Email is not configured.'); } await resend.emails.send({ from: env.RESEND_FROM, to, subject, react }); ``` This is the pattern the contact form and every auth email use, and the reason is worth stating plainly: a form that accepts a message, says "we'll be in touch", and silently drops it is worse than one that says it is unavailable. Optional chaining on a user-facing send turns a misconfiguration into a lie. `RESEND_FROM` needs checking alongside the client. A token without a verified sender address gets you a client that cannot send anything. ## Configuration | Variable | Required | Purpose | | -------------- | -------- | -------------------------------------------------- | | `RESEND_TOKEN` | no | API token; must start with `re_` | | `RESEND_FROM` | no | The `from` address, on a domain verified in Resend | Both are optional to the schema and jointly required to send anything. ## Two transports, chosen by what answers The auth flow does not send through the `resend` client directly. Every verification, reset, magic-link and invitation message goes through `packages/auth/lib/mail.ts`, which picks a transport: * **Resend**, when `RESEND_TOKEN` and `RESEND_FROM` are both set. Unchanged, and what a deployment uses. * **SMTP to `127.0.0.1:1025`** otherwise — the Mailpit container `docker-compose.yml` publishes. Read the message at [localhost:8025](http://localhost:8025). * **Neither**, when nothing answers on that port either: the send throws the same named error it always did, and the sign-up that triggered it fails. The choice is made by whether the sink answers, deliberately not by `NODE_ENV` and not by an environment variable. Both were tried. The end-to-end suite runs production builds under `next start`, so `NODE_ENV` says `production` for a run whose mailbox is a container on the same machine; and a variable is a thing a deployment can set by accident. Either way the discriminator would be lying about the one case it exists for. `requireEmailVerification` is unconditionally on, which it could not be while it was gated on a vendor key. That gating did not merely hide the "Confirm your email" screen for developers without a Resend account — Better Auth signs a user in at sign-up, so an address nobody had proved they controlled got a working session. A sign-up that cannot deliver now fails loudly instead. To get through sign-up locally, in order of preference: * **Run `bun run docker:up`** and click the link in Mailpit. This is the default path and needs no account anywhere. * **Set a real `RESEND_TOKEN`.** Resend's free tier will deliver to the address that owns the account without a verified domain, which is enough to develop against. * **Verify the user by hand.** Open [Prisma Studio](/en/docs/apps/studio), find the row in `user`, and set `emailVerified` to true. Do not switch `requireEmailVerification` off to get past it. Without verification, anyone can hold an account on an address they do not control, which is how invitation and password-reset flows get hijacked. ## Templates Templates live in `packages/email/templates` and are ordinary React components. The package is separate from the apps for two reasons: the [`email` app](/en/docs/apps/email) imports them to render previews, and every other app imports them to send. ```tsx import { resend } from '@kreogen/email'; import { ContactTemplate } from '@kreogen/email/templates/contact'; await resend.emails.send({ from: env.RESEND_FROM, to: env.RESEND_FROM, replyTo: email, subject: `Contact form: ${name}`, react: , }); ``` `replyTo` carrying a user-supplied address is safe only because the schema has already established it parses as a single address. An unvalidated value in any header field is header injection — see [validation](/en/docs/packages/validation). Note that both `from` and `to` are your own address. You can only send from a domain you have verified in Resend, so the sender is never the person who filled in the form; `replyTo` is what makes replying work. ## Auth emails Verification, password reset, magic link and organization invitation are all sent from `packages/auth/lib/options.ts` rather than from a template here. They are deliberately plain text — a link the user has to click is the whole payload, and plain text renders in every client. They use the check-and-throw pattern, logging which variable is missing before throwing, so a misconfigured deploy is recoverable from the container's output rather than presenting as users who never receive anything. ## Previewing ```sh bun dev --filter email ``` The [React Email preview app](/en/docs/apps/email) renders every template at [localhost:3003](http://localhost:3003), with hot reload. It sends nothing. --- For a semantic overview of all documentation, see [/sitemap.md](/sitemap.md) For an index of all available documentation, see [/llms.txt](/llms.txt)