Skill v1.0.1
currentAutomated scan96/100+5 new
version: "1.0.1" name: lunora-setup-mail description: Adds transactional email to a Lunora app with the mail registry item (@lunora/mail). Covers the server-only internal.mail.sendEmail / queueEmail actions, the SEND_EMAIL Cloudflare Email Workers binding, Resend via RESEND_API_KEY, react-email templates via renderEmail, the dev mail catcher (Studio Mail tab), E2E inbox helpers, and inbound mail. Use when the user wants to send verification, password-reset, invite or notification email, runs lunora add email or lunora registry add mail, edits lunora/mail/index.ts, asks why mail isn't delivered or doesn't show up in the Studio, or wants to receive email.
Lunora Setup Mail
The mail registry item wraps @lunora/mail, which validates addresses against header injection before sending. It exposes two internalActions, sendEmail and queueEmail. Both are server-only on purpose: a mailer that lets the client pick the recipient, subject and body is an open relay for phishing through your verified domain. In dev, every send is captured into the Studio Mail tab and nothing is delivered.
If the project has no Lunora backend yet, start with lunora-quickstart.
Step 1: Add the item
lunora add email # asks for the verified destination addresslunora add email --mail-to ops@example.comlunora registry add mail # low-level: leaves the REPLACE_ME@example.com placeholder
Then run pnpm install and lunora codegen. The item does the following:
- Adds
@lunora/mailand@lunora/server. - Copies
lunora/mail/index.tsinto the project. The project owns this file. It definessendEmail, which returns{ id }, andqueueEmail, which returns{ queued: true }. - Adds a
send_emailbinding namedSEND_EMAIL(withdestination_address) towrangler.jsonc. - Writes
MAIL_FROMto.dev.vars.
Codegen emits the two functions as internal.mail.sendEmail and internal.mail.queueEmail. They are not in the client-reachable api. A client call to them returns FUNCTION_NOT_FOUND.
Step 2: Choose the delivery path
The copied file builds its mailer with createMailerFromEnv. That function picks the transport in this order:
| Condition | Transport | |
|---|---|---|
LUNORA_MAIL_CAPTURE=1, or a dev environment (below) | Capture into the Studio Mail tab | |
SEND_EMAIL binding present | Cloudflare Email Workers | |
no binding, RESEND_API_KEY secret set | Resend | |
| none of the above | throws on send (production never captures silently) |
- Cloudflare Email Workers. The binding sends to a single recipient, and only to verified Email Routing destinations. That fits app-to-operator mail. Verify the address and replace the placeholder.
- Resend. Use this to send to arbitrary users. Run
wrangler secret put RESEND_API_KEYand remove thesend_emailbinding. While the binding exists it takes precedence over Resend. - Capture. Capture turns on when every env-name var (
WORKER_ENV,NODE_ENV, …) looks like dev.lunora devsetsWORKER_ENV=development, so capture is on in dev. Capture writes to the root shard. IfSHARDorLUNORA_ADMIN_TOKENis missing, captured mail is discarded and a one-time warning is logged. To deliver for real from dev, setLUNORA_MAIL_CAPTURE=0.
MAIL_FROM is required on every path. It accepts Name <addr@host> or a bare address.
Step 3: Send from a server function
sendEmail is an action because sending is non-transactional network I/O. From a mutation, schedule it so the send runs only after the mutation commits:
import { internal } from "#lunora/_generated/internal.js";import { mutation, v } from "#lunora/_generated/server.js";export const inviteMember = mutation.input({ teamId: v.id("teams"), email: v.string() }).mutation(async ({ ctx, args }) => {// Authorize the caller and persist the invite first. The server decides// the recipient and the content: never forward a client-chosen to/from/html.await ctx.scheduler.runAfter(0, internal.mail.sendEmail, {to: args.email,subject: "You're invited",html: "<p>Click the link to join.</p>",});});
Sending to an arbitrary args.email needs the Resend path in production: the SEND_EMAIL binding only reaches its verified destination.
From an action, call await ctx.runAction(internal.mail.sendEmail, { … }).
If a client needs to trigger a send, write a public action for that one purpose. It should take only business inputs (for example { orderId }), check ctx.auth, derive the recipient on the server, rate-limit the call (@lunora/ratelimit), and then call internal.mail.sendEmail.
React templates
A React element can't be serialized across the RPC boundary, so render it to html and text first. Rendering this way keeps dev capture working:
import { renderEmail } from "@lunora/mail"; // needs `react` installedimport { WelcomeEmail } from "./emails/welcome";const { html, text } = await renderEmail(<WelcomeEmail name={name} />);await ctx.runAction(internal.mail.sendEmail, { to, subject: "Welcome", html, text });
For the auth flows, lunora add auth-emails adds ready-made templates.
Queueing (optional)
queueEmail requires a Cloudflare Queue producer binding passed as createMailerFromEnv(env, { queue }) in mailer(), and the item doesn't add one. Without it, the call throws ` queue binding is required for mailer.queue() , except under capture, where it sends straight to the capture inbox so dev flows keep working. A consumer builds its mailer with createMailerFromEnv too, drains the queue with consumeQueuedSend(mailer, message.body) and then calls message.ack()` for each message. With a queue bound in dev, the message still goes through the queue and the consumer's mailer captures it into the inbox. Queues deliver at least once: with per-message acks a later throw retries only the unacknowledged messages, while without them a single throw resends the whole batch. The full wiring is in the "Queueing" section of the item README.
Testing and inbound
- E2E.
@lunora/mail/testingexportswaitForMail({ baseUrl, adminToken, to, subjectMatch })andextractLink(mail, { match }). Together they let you read the captured inbox and pull out a verification or reset link. - Receiving mail.
@lunora/mail/inboundprovidescreateInboundEmailHandler,parseInboundEmailanddispatchToLunoraFunction. They route Email Routing messages into a Lunora function. Mount them with the generated builder's.onEmail(...).
Verify
- Run
lunora devand trigger a send. WithSHARDandLUNORA_ADMIN_TOKENset, the message should appear in the Studio Mail tab; if either is missing, capture discards it and logs a warning. - Before deploying, run
lunora doctor. It flags the placeholder destination. Confirm thatMAIL_FROMis set, and that either the binding uses a verified destination orRESEND_API_KEYis set with the binding removed.