| name | lunora-setup-mail |
| description | Adds transactional email to a Lunora app. Use for sending mail (verification, password reset, invites, notifications) via `lunora registry add mail`, the `sendEmail` / `queueEmail` actions, the `SEND_EMAIL` Cloudflare Email Workers binding, Resend, React email templates, and the dev mail catcher. |
Lunora Setup Mail
Wire transactional email into a Lunora app using the mail registry item, which
is built on @lunora/mail (a Cloudflare Email Workers transport with
header-injection-safe address handling) and exposes a sendEmail action plus a
fire-and-forget queueEmail action. In dev, every send is captured into the
Studio Mail tab instead of going out.
When to Use
- Sending app→user mail: invites, notifications, receipts.
- Delivering verification / password-reset mail from
@lunora/auth.
- Using a React (
react-email) template or a hosted provider (Resend).
When Not to Use
- The project has no Lunora backend yet — use
lunora-quickstart first.
- Mail is already installed and you just want to send — call
ctx.runAction(api.mail.sendEmail, …) or client.action("mail/sendEmail", …).
Workflow
- Add the
mail item.
- Configure the
SEND_EMAIL binding (or a provider) and MAIL_FROM.
- Regenerate types with
lunora codegen.
- Send mail from a function (or the client); render a React template if needed.
Step 1: Add the item
lunora registry add mail
This:
- Adds
@lunora/mail and @lunora/server to package.json (run
pnpm install afterwards).
- Copies
lunora/mail/index.ts (the sendEmail / queueEmail actions) into
your project — it is yours to edit.
- Adds a
send_email binding (SEND_EMAIL, with a destination_address
placeholder) to wrangler.jsonc and scaffolds MAIL_FROM (the default
sender) into .dev.vars.
Step 2: Configure delivery
| Name | Where | Notes |
|---|
SEND_EMAIL | wrangler.jsonc → send_email[] | Cloudflare Email Workers binding. Single-recipient; only verified destinations. |
MAIL_FROM | var (.dev.vars / wrangler.jsonc) | Default sender address. |
RESEND_API_KEY | secret (optional) | Use a hosted provider instead — pass apiKey to createMailer in lunora/mail. |
For production with Cloudflare Email Workers, set up
Email Routing: verify a
destination address and replace the REPLACE_ME@example.com placeholder. Prefer
Resend? Pass apiKey (or a custom transport) to createMailer in your copied
lunora/mail/index.ts.
In lunora dev (WORKER_ENV=development) the scaffold swaps in @lunora/mail's
capture transport automatically: every send — including @lunora/auth's
verification and forgot-password mail — is intercepted and surfaced in the
Studio Mail tab. Nothing leaves your machine and no provider setup is needed.
Step 3: Regenerate types
lunora codegen
The functions surface in the generated api as api.mail.sendEmail and
api.mail.queueEmail.
Step 4: Send mail
From another function
sendEmail is an action (sending is non-transactional network I/O). From a
mutation, schedule it as a follow-up so the request is not blocked:
import { mutation, v } from "@lunora/server";
import { api } from "./_generated/api";
export const inviteUser = mutation.input({ email: v.string() }).mutation(async ({ ctx, args: { email } }) => {
await ctx.scheduler.runAfter(0, api.mail.sendEmail, {
to: email,
subject: "You're invited",
html: "<p>Click the link to join.</p>",
});
});
From a client
await client.action("mail/sendEmail", {
to: "alice@example.com",
subject: "Welcome",
text: "Thanks for signing up!",
});
With a React email template
React elements are not JSON-serializable across the RPC boundary, so the
sendEmail args take html / text. To use a react-email template, render it
where you call the mailer — edit lunora/mail/index.ts to pass react straight
into mailer.send:
import { createMailer } from "@lunora/mail";
import { env } from "cloudflare:workers";
import { WelcomeEmail } from "./emails/Welcome";
await createMailer({ apiKey: env.RESEND_API_KEY as string, from: env.MAIL_FROM as string }).send({
to: "alice@example.com",
subject: "Welcome",
react: <WelcomeEmail name="Alice" />,
});
Common Pitfalls
- Expecting prod email to "just work". Dev captures into the Studio;
production needs the
SEND_EMAIL binding (a verified destination) or
RESEND_API_KEY.
- Calling
sendEmail as a query/mutation. It is an action — invoke it via
ctx.runAction / ctx.scheduler.runAfter / client.action, never ctx.db.
- Using
queueEmail without a Queue binding. It requires a Cloudflare
Queue producer binding; until you add one, @lunora/mail throws
`queue` binding is required for mailer.queue(). The item does not add
the Queue for you — see the mail README's "Queueing" section.
- Passing a React element through the action args. Render it inside the
mailer (
mailer.send({ react })), not across the RPC boundary.
Checklist