Skip to content
DocspackagesDocumentation

@lunora/mail

Transactional email for Lunora — Cloudflare Email Workers or Resend, React templates, queue-backed sends.

PackagesMail

@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 / bcc from server-trusted state (a looked-up user record), never from raw request input, and prefer a fixed or allowlisted from. 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=pass for the attacker's OWN domain on a message forging someone else's From.

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:

ExportPurpose
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-onlyMailer, 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" });