---
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)