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

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:

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

VariableRequiredPurpose
RESEND_TOKENnoAPI token; must start with re_
RESEND_FROMnoThe 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.
  • 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, 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 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 email

The React Email preview app renders every template at localhost:3003, with hot reload. It sends nothing.