Transactional Emails
Sending transactional email with Resend and React Email.
@kreogen/email exports a Resend client and a set of
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
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:
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:
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_TOKENandRESEND_FROMare both set. Unchanged, and what a deployment uses. - SMTP to
127.0.0.1:1025otherwise — the Mailpit containerdocker-compose.ymlpublishes. Read the message at 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:upand 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, find
the row in
user, and setemailVerifiedto 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 imports them to render previews, and every
other app imports them to send.
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: <ContactTemplate email={email} message={message} name={name} />,
});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.
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
bun dev --filter emailThe React Email preview app renders every template at localhost:3003, with hot reload. It sends nothing.