Docs / Email / Sending email
Sending email
Every transactional email in Keel funnels through one function, sendEmail in lib/email.ts. Routes and hooks never talk to Resend directly, they hand it a to, a subject, and a rendered React Email component:
import { sendEmail } from "@/lib/email";
import { WelcomeEmail } from "@/emails/welcome";
await sendEmail({
to: user.email,
subject: "Welcome to Keel",
react: WelcomeEmail({ name: user.name, url: dashboardUrl }),
});
It degrades instead of breaking
sendEmail only constructs a Resend client when RESEND_API_KEY is set. On a fresh clone with no key, it logs the email it would have sent and returns, so sign-up, invites, and billing all work end to end locally before a buyer has an email provider configured. This is the same "optional integration degrades to a no-op" convention used by lib/rate-limit.ts and lib/ai/provider.ts.
It also never throws. A Resend failure is caught and logged, not re-raised. That is deliberate: email is a side effect of an action (an invite was created, a payment succeeded), not the action itself, so a bounced send should never roll back or 500 the request that triggered it. Callers fire it and move on.
Default the from address with || , not ??
FROM_EMAIL falls back with process.env.RESEND_FROM_EMAIL || "Keel <onboarding@resend.dev>". A blank RESEND_FROM_EMAIL= line in .env.local is a defined, non-nullish empty string, so ?? would keep the empty value and Resend rejects an empty from with a confusing "domain is invalid" error. This broke real sending once, use || for every string env fallback in this codebase.
The default sender
Out of the box the sender is onboarding@resend.dev, Resend's shared domain that works with zero DNS setup. Resend restricts it to delivering only to your own verified account address, so it is fine for testing and useless in production. Verify a domain in the Resend dashboard, then set RESEND_FROM_EMAIL to an address on it (Keel <hello@yourdomain.com>).
Where each email fires
The producers are spread across the codebase, each next to the event that warrants a message:
lib/auth.ts, Better Auth callbacks send verification, password reset, magic link, and the post-verification welcome email.app/api/teams/[teamId]/invites/route.ts, the team invite email on invite creation.lib/billing/sync.ts, payment receipt, payment failed, and subscription canceled, driven off the provider webhook so the email reflects the state the provider actually confirmed, not an optimistic local guess.app/api/contact/route.ts, the contact form forwards the message as an email.
See Email templates for the components these pass to react.