@lunora/mail wraps @visulima/email so you can send transactional email
from inside an action. The default transport is Cloudflare Email Workers;
Resend is a built-in option, and you can pass any transport you wire
yourself. It renders React Email templates inline and can offload delivery to a
Cloudflare Queue.
createMailer needs from plus exactly one transport: cloudflareSend (the
Cloudflare default), apiKey (Resend), or an explicit transport. Passing none
throws.
import { createMailer } from "@lunora/mail";
export const mailer = createMailer({
apiKey: env.RESEND_API_KEY,
from: "Lunora <noreply@lunora.sh>",
});Inside a Worker, prefer createMailerFromEnv(env): it reads MAIL_FROM,
captures every send into the studio's Mail inbox in a dev environment, and
otherwise delivers via a SEND_EMAIL binding (pass cloudflareSend) or
RESEND_API_KEY.
import { createMailerFromEnv } from "@lunora/mail";
const mailer = createMailerFromEnv(env, {
cloudflareSend: async (from, to, raw) => {
const { EmailMessage } = await import("cloudflare:email");
await env.SEND_EMAIL.send(new EmailMessage(from, to, raw));
},
});Pass a custom transport to swap providers; pass a Cloudflare queue
binding to enable deferred sends via mailer.queue(...).
Sending from an action
import { action, v } from "@/lunora/_generated/server";
import { mailer } from "./mail";
export const welcomeEmail = action.input({ userId: v.id("users") }).action(async ({ ctx, args: { userId } }) => {
const user = await ctx.runQuery("users:get", { userId });
await mailer.send({
to: user.email,
subject: "Welcome to Lunora",
html: `<p>Hi ${user.name} — your account is ready.</p>`,
});
});Security: recipient policy & HTML content are yours
The mailer blocks header/CRLF/comma injection in addresses, but it does not decide who you send to or what HTML you render:
- Open relay. Derive
to/cc/bccfrom server-trusted state (a looked-up user record), never from raw request input, and prefer a fixed or allowlistedfrom. Sending to an arbitrary user-supplied address turns your deployment into a spam relay. - Template XSS / content injection. Treat template HTML like any other HTML sink: never interpolate untrusted data into raw markup without escaping. The mailer sends whatever HTML you hand it verbatim.
React Email templates
Pass a @react-email/components element through the react field. The
mailer renders it via renderEmail (a @react-email/render wrapper) and
fills in html + text if you didn't provide them yourself:
import Welcome from "../emails/Welcome";
await mailer.send({
to: user.email,
subject: "Welcome",
react: <Welcome name={user.name} />,
});Queueing
mailer.queue(options) serialises the rendered payload and hands it to the
Cloudflare Queue binding you passed as queue. React elements are NOT
structured-cloneable, so the queue body always carries pre-rendered html/
text, never the original JSX.
A consumer Worker re-hydrates the payload and sends it with
consumeQueuedSend(mailer, body) — it validates the untrusted message body, then
calls mailer.send(...) for you and returns { id }. Call message.ack() after
each successful send: Cloudflare Queues are at-least-once, so without a per-message
ack a single throw retries the WHOLE batch — resending every already-delivered
email in it, not just the one that failed:
import { consumeQueuedSend, createMailer } from "@lunora/mail";
export default {
queue: async (batch, env) => {
const mailer = createMailer({ apiKey: env.RESEND_API_KEY, from: "Acme <noreply@acme.test>" });
for (const message of batch.messages) {
const { idempotencyKey } = message.body;
// The dedupe is YOURS to do. Nothing downstream does it for you: no
// transport forwards the key to the provider (Resend dedupes on an
// `Idempotency-Key` REQUEST header, which the provider client does not
// expose), so this store is the only thing standing between a
// redelivery and a second copy of the same email.
if (await env.SENT.get(idempotencyKey)) {
message.ack();
continue;
}
await consumeQueuedSend(mailer, message.body);
await env.SENT.put(idempotencyKey, "1", { expirationTtl: 86_400 });
message.ack();
}
},
};Each queued send carries a stable idempotencyKey — a caller-supplied one, or one
generated at enqueue time. The ack narrows retries to the message that actually
failed, and the key is what stops a redelivery from mailing a second copy: skip
the store and the window where the provider accepted the send but the worker died
before acking sends a duplicate every time.
It narrows that window rather than closing it, and nothing on this path can close it. The mark is written after the provider accepted the message, so a crash in between still redelivers and resends, and KV is eventually consistent — a redelivery seconds later can miss a mark that was written. Delivery is at-least-once end to end. If a second copy is unacceptable, claim the key in a strongly-consistent store (a Durable Object, or a D1 row) before the send and release it on failure; that trades a possible duplicate for a possible unsent message, which is the only other side of this coin.
Receiving email
@lunora/mail/inbound is the inbound counterpart: it turns a Cloudflare Email
Worker delivery
into a call to one of your Lunora functions. The package never imports
cloudflare:email: the generated worker entry supplies the real binding, and
the package stays unit-testable in plain Node (exactly like the outbound
Cloudflare transport).
A Cloudflare Email Worker delivers inbound mail to a top-level email(message, env, ctx) export, a sibling of fetch/scheduled. Export one built by
createInboundEmailHandler:
import { authenticatesFrom, createInboundEmailHandler, dispatchToLunoraFunction, parseInboundEmail } from "@lunora/mail/inbound";
export const email = createInboundEmailHandler({
parse: parseInboundEmail,
// The sender gate. `authenticatesFrom` accepts only a DMARC/SPF/DKIM pass
// that is ALIGNED with the `From` domain — see the warning below.
verify: authenticatesFrom,
dispatch: dispatchToLunoraFunction({
shard: env.SHARD,
functionPath: "inbound:onEmail",
shardKey: "__root__",
}),
});The handler reads message.raw, parses it into a normalised InboundEmail
({ from, to, subject?, messageId?, inReplyTo?, references?, headers, text?, html?, attachments, authentication }, every header CR/LF-checked), and
dispatches it. dispatchToLunoraFunction posts an RPC envelope to the root shard
stub (the same admin-RPC-over-shard path the dev mail catcher uses), calling the
named mutation/action with the parsed message as its args. The RPC is marked a
trusted system dispatch — the same marker the scheduler and cron paths set —
so the target may be an internalMutation / internalAction. It needs
LUNORA_ADMIN_TOKEN to authorize the RPC (read from env by default).
Failures split by whether a redelivery could ever succeed. A parse or verify
failure is permanent — the same bytes fail the same way — so the handler calls
message.setReject(reason) and Cloudflare bounces the mail to the sender;
override onError to log/forward/swallow instead. A dispatch failure (a
shard 502, a briefly-absent LUNORA_ADMIN_TOKEN) is usually transient, but
Cloudflare gives an inbound worker no way to say "try later": setReject is a
permanent SMTP error and an uncaught throw is permanent too, just opaque
(521 Upstream error). There is no inbound redelivery to fall back on. By
default the handler therefore bounces with the same fixed generic reason.
To stop a two-second blip losing a legitimate email, absorb the retry inside the
worker: pass retain and the handler hands the parsed message to it and
accepts the SMTP session, because the message is now owned rather than lost.
The sink is yours — a queue, a Durable Object, an alarm:
export const email = createInboundEmailHandler({
parse: parseInboundEmail,
dispatch: dispatchToLunoraFunction({ shard: env.SHARD, functionPath: "inbound:onEmail" }),
retain: async (parsed, { env }) => env.INBOUND_RETRY.send(parsed),
});If retain itself throws there is nowhere durable to put the message, so it
falls back to the generic bounce (the real reason is logged server-side, never
reflected to the sender). A dispatch that knows one of its own failures is
permanent should call context.message.setReject(...) and return instead of
throwing, so it bounces without being queued for a retry that can never succeed.
Inbound mail is untrusted and dispatch is privileged. Cloudflare Email Routing authenticates only the recipient domain, not the sender (email.from and the
body are trivially spoofable), and dispatchToLunoraFunction dispatches as a trusted system call, so make the target an internalMutation /
internalAction: a public mutation reachable from inbound mail is equally reachable from any browser client, which can post a forged
from/to/text straight into your inbox table. The dispatch carries no caller identity, so an rls() policy on the target sees an anonymous caller — it
is not bypassed; do the authorization in the handler. Never make a trust decision on email.from. Gate on the DKIM/SPF/DMARC verdicts in
email.authentication via the verify hook before dispatch, and treat the function input as fully attacker-controlled. A bare pass is not enough: SPF
vouches for the envelope MAIL FROM domain and DKIM for the signing d=, both of which the sender picks. authentication.dkim / .spf / .dmarc are
lists — one header legitimately reports a method more than once (an ESP-relayed message is DKIM-signed twice) — and each entry carries the domain it
is about. Do not hand-roll that check: pass verify: authenticatesFrom and let @lunora/mail answer it. It accepts only when ANY entry has result === "pass" and a domain equal to the From address's domain, treating an entry with a null domain as unauthenticated and an empty list as "not
reported" (a DMARC pass already checked alignment). Every copy of this predicate written by hand has been one && away from accepting a genuine spf=pass
dkim=passfor the attacker's OWN domain on a message forging someone else'sFrom.
The receiving function is an ordinary internal mutation/action (the default
resolveArgs passes the whole InboundEmail, so declare the fields you use):
import { internalMutation, v } from "@/lunora/_generated/server";
export const onEmail = internalMutation
.input({
from: v.string(),
to: v.array(v.string()),
subject: v.optional(v.string()),
text: v.optional(v.string()),
})
.mutation(async ({ ctx, args: { from, subject, text } }) => {
await ctx.db.insert("inbox", { from, subject: subject ?? "", body: text ?? "" });
});wrangler config
Inbound delivery is configured in Cloudflare, not by codegen. Add an Email
Routing rule (dashboard or
wrangler) that routes an address to this Worker. If your function replies or
forwards, also declare a send_email binding:
{
"send_email": [{ "name": "OUTBOUND" }],
}Lunora validates the send_email binding shape but does not manage routing
rules; that lifecycle stays in Cloudflare.
Public API
@lunora/mail:
| Export | Purpose |
|---|---|
createMailer(options) | Build a Mailer. Needs from plus one of cloudflareSend / apiKey / transport |
createMailerFromEnv(env, options?) | Build a Mailer from a Worker env: captures in dev, else delivers via Cloudflare/Resend |
createCloudflareTransport(options) | Cloudflare Email Workers transport (single-recipient; rejects cc/bcc) |
createResendTransport(apiKey, from) | Resend transport |
createCaptureTransport(sink) | Dev capture transport: persists to a MailboxSink instead of delivering |
createCaptureSink(env, rootShard?) | MailboxSink that records into the studio's root-shard inbox |
shouldCaptureMail(env) | Whether the current env should capture (LUNORA_MAIL_CAPTURE, else dev detection) |
renderEmail(element) | Render a React Email element to { html, text } |
toQueuedPayload(options) | Narrow SendOptions to its serializable QueuedSend (drops react) |
consumeQueuedSend(mailer, body) | Validate a queued payload and mailer.send(...) it; returns { id } |
| Type-only | Mailer, MailTransport, SendOptions, SendPayload, QueueLike, LunoraMailOptions, QueuedSend, CapturedMail, MailboxSink, CloudflareSend, CloudflareTransportOptions, FromEnvOptions, MailEnv |
@lunora/mail/inbound: createInboundEmailHandler(options),
dispatchToLunoraFunction(options), parseInboundEmail(raw),
authenticatesFrom(email) (the canonical verify gate — true only for a
DMARC/SPF/DKIM pass aligned with the From domain), and the inbound types
(InboundEmail, InboundAttachment, InboundAuthentication,
InboundEmailHandlerOptions, InboundDispatch, InboundVerify, …).
@lunora/mail/testing (dev/test-only): waitForMail(options),
listCapturedMail(options), extractLink(mail, { match? }), plus
InboxOptions / WaitForMailOptions.
Testing
For a unit test, build a stub transport ({ send: async () => ({ id: "stub" }) }
is enough) and pass it as transport. It needs no network and no API keys.
For end-to-end flows that depend on email (sign-up verification,
forgot-password, magic links), @lunora/mail/testing reads the dev capture
inbox over the admin RPC so a test can drive "request reset → read the email →
follow the link" deterministically. It needs the app's base URL and the admin
token (LUNORA_ADMIN_TOKEN):
import { extractLink, waitForMail } from "@lunora/mail/testing";
const mail = await waitForMail({
baseUrl: "http://localhost:8787",
adminToken: process.env.LUNORA_ADMIN_TOKEN!,
to: "alice@example.com",
subjectMatch: "Reset your password",
});
const link = extractLink(mail, { match: "/reset-password" });