@lunora/cli 1.0.0-alpha.362 → 1.0.0-alpha.363

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,67 +1,55 @@
1
1
  ---
2
2
  name: lunora-setup-hyperdrive-global
3
- description: Use PlanetScale (Postgres/MySQL via Cloudflare Hyperdrive) as a first-class, REACTIVE `.global()` storage backend for a Lunora app — and migrate an existing D1 `.global()` dataset to it. Use for `@lunora/hyperdrive/global`, `.global({ backend: "hyperdrive" })`, `createHyperdriveGlobalCtxDb`, the app-builder `.hyperdriveGlobal()` declaration, the `HYPERDRIVE` binding pointing at PlanetScale, and `lunora migrate d1-to-hyperdrive`. NOT the same as `@lunora/hyperdrive` (which is an action-only, non-reactive `ctx.sql`).
3
+ description: Stores a Lunora app's own `.global()` tables in Postgres or MySQL (PlanetScale, Neon, RDS, or any Hyperdrive-reachable database) through Cloudflare Hyperdrive instead of D1. Lunora owns the schema, and live queries stay fully reactive. Also migrates an existing D1 `.global()` dataset with `lunora migrate d1-to-hyperdrive`. Covers `.global({ backend: "hyperdrive" })`, `@lunora/hyperdrive/global` (`buildPgExec` / `buildMysqlExec`, `createHyperdriveGlobalCtxDb`), the app builder's `.hyperdriveGlobal({ engine, exec })`, and the `HYPERDRIVE` binding. Use when the user wants to "move global tables to PlanetScale/Postgres", "use Postgres instead of D1", or "migrate off D1", or edits `.global({ backend: "hyperdrive" })`. For querying an existing, externally owned database from an action (non-reactive `ctx.sql`), use `lunora-setup-hyperdrive` instead.
4
4
  ---
5
5
 
6
6
  # Lunora Setup: Hyperdrive Global Backend (Postgres/MySQL)
7
7
 
8
- Store Lunora `.global()` tables in a **Postgres or MySQL database reached
9
- through [Cloudflare Hyperdrive](https://developers.cloudflare.com/hyperdrive/)**
10
- — **PlanetScale**, Neon, RDS, or any Hyperdrive-reachable database — instead of
11
- D1, as a **drop-in, fully reactive** backend. (PlanetScale on Cloudflare is the
12
- headline use case; nothing here is PlanetScale-specific.)
8
+ Store `.global()` (cross-tenant) tables in a Postgres or MySQL database reached
9
+ through Cloudflare Hyperdrive instead of D1. PlanetScale on Cloudflare is the
10
+ typical case, but nothing here is PlanetScale-specific.
13
11
 
14
- > **`@lunora/hyperdrive/global` vs. `@lunora/hyperdrive` (`ctx.sql`).** The
15
- > `ctx.sql` surface is an action-only, **non-reactive** escape hatch for
16
- > integrating a legacy database. `@lunora/hyperdrive/global` is different: Lunora
17
- > **owns** the schema (column-per-field, like D1) and routes every write through
18
- > its own store core, so live queries stay reactive — the writer is injected as
19
- > `globalDb` and the shard DO's broadcast hook re-runs subscriptions exactly as
20
- > with D1.
21
-
22
- ## When to Use
23
-
24
- - You want `.global()` (cross-tenant) tables to live in PlanetScale rather than
25
- D1 — e.g. for larger datasets, existing SQL tooling, or unified Cloudflare
26
- billing.
27
- - You're migrating an existing D1 `.global()` dataset to PlanetScale.
12
+ > **This is not `ctx.sql`.** `@lunora/hyperdrive`'s `ctx.sql`
13
+ > (`lunora-setup-hyperdrive`) is an action-only, non-reactive client for a
14
+ > database Lunora doesn't own. `.global({ backend: "hyperdrive" })` is the opposite:
15
+ > Lunora owns the tables (one column per field, like D1), and every write goes
16
+ > through Lunora's store core. Subscriptions re-run exactly as they do with D1.
17
+ > The live-query guarantee covers only writes made through Lunora (`ctx.db`).
18
+ > Rows written to the database directly are invisible to live queries.
28
19
 
29
20
  ## When Not to Use
30
21
 
31
- - You only need to read/write a **legacy** external DB from an action — use
32
- `@lunora/hyperdrive` (`ctx.sql`) instead; it's lighter and action-scoped.
33
- - Your global data is small and D1 is fine — bare `.global()` (D1) needs no
34
- extra binding or driver.
22
+ - You only need to read or write a legacy database from an action: use
23
+ `lunora-setup-hyperdrive`.
24
+ - Your global data is small and D1 is fine: bare `.global()` needs no extra
25
+ binding or driver.
35
26
 
36
27
  ## How it works
37
28
 
38
- PlanetScale reuses Lunora's dialect-parameterized store core (the same one D1
39
- uses): a `SqlDialect` (Postgres or MySQL) shapes the SQL, and a Hyperdrive-backed
40
- `SqlExec` runs it from inside the Durable Object that hosts the global writer.
41
- Values are stored SQLite-shaped (boolean → 1/0, JSON → text/json, bigint →
42
- decimal), so the value codec is shared with D1.
29
+ The same dialect-parameterized store core that backs D1 runs here with a
30
+ Postgres or MySQL `SqlDialect`. A Hyperdrive-backed `SqlExec` executes it from
31
+ inside the Durable Object that hosts the global writer. Values are stored in the
32
+ same shapes D1 uses (boolean → 1/0, JSON → text/json, bigint → decimal).
43
33
 
44
- ## Step 1: Install the package + a driver
34
+ ## Step 1: Install
45
35
 
46
36
  ```bash
47
- pnpm add @lunora/hyperdrive/global
48
- # choose the driver matching your engine (optional peer deps — none is bundled):
49
- pnpm add postgres # PlanetScale Postgres → fromPostgresJs
50
- # or
51
- pnpm add mysql2 # PlanetScale MySQL → mysql2/promise
37
+ pnpm add @lunora/hyperdrive @lunora/sql-store
38
+ pnpm add postgres # Postgres engine → buildPgExec(fromPostgresJs(...))
39
+ # or: pnpm add mysql2 → buildMysqlExec(mysql2/promise pool), MySQL 8.0+
52
40
  ```
53
41
 
54
- ## Step 2: Create the Hyperdrive binding pointing at PlanetScale
42
+ `@lunora/hyperdrive/global` is a subpath export of `@lunora/hyperdrive`, not a
43
+ separate package. Codegen requires `@lunora/sql-store` too, because the generated
44
+ `_generated/app.ts` imports its `SqlExec` types. Install the driver that matches
45
+ your engine. Drivers are optional peer dependencies.
55
46
 
56
- Create a PlanetScale database from the Cloudflare dashboard (unified billing),
57
- then a Hyperdrive config over its connection string:
47
+ ## Step 2: Create the Hyperdrive binding
58
48
 
59
49
  ```bash
60
50
  wrangler hyperdrive create my-db --connection-string="postgres://user:pass@host/db" # gitleaks:allow -- placeholder
61
51
  ```
62
52
 
63
- Add the binding to `wrangler.jsonc` (use `localConnectionString` for `lunora dev`):
64
-
65
53
  ```jsonc
66
54
  {
67
55
  "hyperdrive": [
@@ -70,15 +58,14 @@ Add the binding to `wrangler.jsonc` (use `localConnectionString` for `lunora dev
70
58
  }
71
59
  ```
72
60
 
73
- The binding is required, not optional: with no `hyperdrive` entry at all,
74
- `lunora dev` and `lunora deploy` both refuse to start. The NAME is yours — the
75
- validator only checks that some binding exists, because your `exec` selector is
76
- what picks it.
61
+ A binding is required. Without any `hyperdrive` entry, config validation errors
62
+ and `lunora dev` / `lunora deploy` won't start. You choose the name: the
63
+ validator only checks that at least one binding exists, and your `exec` selector picks it.
77
64
 
78
- > **Read-your-writes:** point the Hyperdrive config at the **primary** (or pin
79
- > writes to it) so a write then immediate read isn't served by a stale replica.
65
+ Point Hyperdrive at the primary (or pin writes to it) so you can read your own
66
+ writes. A replica can serve a stale read right after a write.
80
67
 
81
- ## Step 3: Mark tables `.global({ backend: "hyperdrive" })`
68
+ ## Step 3: Mark tables
82
69
 
83
70
  ```ts
84
71
  // lunora/schema.ts
@@ -89,88 +76,99 @@ export default defineSchema({
89
76
  });
90
77
  ```
91
78
 
92
- A bare `.global()` stays on D1. **One backend per app** — mixing D1- and
93
- PlanetScale-backed global tables in the same app isn't supported yet (codegen
94
- errors clearly).
79
+ Bare `.global()` stays on D1. An app can use only one global backend: codegen fails
80
+ if the schema mixes D1 and Hyperdrive global tables.
95
81
 
96
- ## Step 4: Wire `.hyperdriveGlobal()` in the app composition
82
+ ## Step 4: Wire `.hyperdriveGlobal()`
97
83
 
98
- Codegen emits a `.hyperdriveGlobal()` builder method. Supply the engine and an `exec`
99
- built from the Hyperdrive binding (cache the driver on the DO instance; rebuild
100
- lazily after hibernation):
84
+ Codegen emits a `.hyperdriveGlobal({ engine, exec, origin? })` builder method.
85
+ `exec` builds a `SqlExec` from `env`:
101
86
 
102
87
  ```ts
103
- import { buildPgExec } from "@lunora/hyperdrive/global";
104
88
  import { fromPostgresJs } from "@lunora/hyperdrive";
89
+ import { buildPgExec } from "@lunora/hyperdrive/global";
105
90
  import postgres from "postgres";
106
91
 
107
- export default defineApp<Env>()
92
+ import { defineApp } from "../lunora/_generated/app.js";
93
+
94
+ const app = defineApp<Env>()
108
95
  .shard((env) => env.SHARD)
109
96
  .hyperdriveGlobal({
110
97
  engine: "postgres",
111
98
  exec: (env) => buildPgExec(fromPostgresJs(postgres(env.HYPERDRIVE.connectionString))),
112
- });
99
+ })
100
+ .build();
101
+
102
+ export const ShardDO = app.ShardDO;
113
103
  ```
114
104
 
115
- For MySQL, create the pool with the **`FOUND_ROWS`** flag (the optimistic-concurrency
116
- guard needs matched-row counts, or idempotent writes raise spurious conflicts):
105
+ For MySQL, create the pool with the `FOUND_ROWS` flag. The optimistic-concurrency
106
+ guard needs matched-row counts. Without the flag, an idempotent write reports
107
+ zero affected rows and raises a spurious conflict. `buildMysqlExec` throws at
108
+ construction when the flag is missing. If it can't read the flag, it warns once instead.
117
109
 
118
110
  ```ts
119
111
  import { buildMysqlExec } from "@lunora/hyperdrive/global";
120
112
  import mysql from "mysql2/promise";
121
113
 
114
+ // inside the builder chain:
122
115
  .hyperdriveGlobal({
123
116
  engine: "mysql",
124
117
  exec: (env) => buildMysqlExec(mysql.createPool({ uri: env.HYPERDRIVE.connectionString, flags: ["FOUND_ROWS"] })),
125
- });
118
+ })
126
119
  ```
127
120
 
128
- ## Step 5: Regenerate + run
121
+ Outside the app builder, for example in tests or a custom host,
122
+ `createHyperdriveGlobalCtxDb({ engine, exec, ... })` builds the same reactive writer
123
+ directly.
124
+
125
+ ## Step 5: Regenerate and run
129
126
 
130
127
  ```bash
131
- lunora codegen # wires config.hyperdriveGlobal and the .hyperdriveGlobal() method
128
+ lunora codegen
132
129
  lunora dev
133
130
  ```
134
131
 
135
- Tables auto-provision on first use (the runtime runs the PlanetScale DDL through
136
- the dialect — no manual migration needed), and subscriptions are reactive.
132
+ Tables provision automatically on first use, because the runtime runs the DDL
133
+ through the dialect. You don't write or commit SQL migrations. `CREATE TABLE IF NOT EXISTS` never
134
+ reshapes an existing table, though. When a column's validator starts accepting
135
+ null, the store reports the `ALTER` statement to run. MySQL tables created before
136
+ the `utf8mb4_0900_bin` column collation need a one-off
137
+ `ALTER TABLE … CONVERT TO CHARACTER SET utf8mb4 COLLATE utf8mb4_0900_bin`.
138
+ See `lunora-migration-helper` for schema changes.
137
139
 
138
- ## Migrating an existing D1 dataset → PlanetScale
140
+ **Verify:** subscribe to a query over a hyperdrive-backed table, write through a
141
+ mutation, and confirm that the subscription updates.
139
142
 
140
- Blue-green: deploy the new PlanetScale-backed worker alongside the old D1 one,
141
- then copy the `.global()` data:
143
+ Postgres deployments can also serve `ctx.vectors` from pgvector instead of a Vectorize
144
+ binding with `createPgVectorIndex` (from `@lunora/hyperdrive/global`). It supports
145
+ equality-only metadata filters.
146
+
147
+ ## Migrating an existing D1 dataset
148
+
149
+ The flow is blue-green: deploy the Hyperdrive-backed worker next to the D1 one,
150
+ then copy the data:
142
151
 
143
152
  ```bash
144
153
  lunora migrate d1-to-hyperdrive \
145
- --from-url https://old-d1.example.com --from-token "$D1_ADMIN_TOKEN" \
146
- --to-url https://new-ps.example.com --to-token "$PS_ADMIN_TOKEN" \
147
- --tables settings,orders # omit to move every global table
148
- # --out dump.ndjson # keep the intermediate dump to inspect
154
+ --from-url https://old-d1.example.com --from-token "$D1_ADMIN_TOKEN" \
155
+ --to-url https://new-hd.example.com --to-token "$HD_ADMIN_TOKEN" \
156
+ --tables settings,orders # omit to move every global table; --out dump.ndjson keeps the dump
149
157
  ```
150
158
 
151
- It streams the source's global rows to NDJSON, imports them into the target
152
- (whose `.global({ backend: "hyperdrive" })` tables route the writes to
153
- PlanetScale), and verifies the row counts match. Rows whose `_id` already exists
154
- are reported as conflicts, not duplicated.
155
-
156
- > **Direct external writes** to PlanetScale (bypassing Lunora) are invisible to
157
- > live queries — same as D1. Let Lunora own the writes.
159
+ The command exports the source's global rows to NDJSON, imports them into the
160
+ target, and compares row counts. It refuses to run when the source and target
161
+ resolve to the same URL, and non-localhost URLs must use https. Rows whose `_id`
162
+ already exists in the target are reported as conflicts and not duplicated.
163
+ A count mismatch is a warning: inspect it with `--out`, resolve it, and re-run.
164
+ Tokens default to `--token` / `LUNORA_ADMIN_TOKEN`.
158
165
 
159
166
  ## Common Pitfalls
160
167
 
161
- 1. **Mixing backends.** One global backend per app (codegen errors on a mix).
162
- 2. **Missing driver.** `postgres`/`mysql2` are optional peer deps — install the
163
- one matching your engine.
164
- 3. **Stale-replica reads.** Point Hyperdrive at the primary for read-your-writes.
165
- 4. **Expecting external writes to be reactive.** They aren't — route writes
166
- through Lunora (`ctx.db`).
167
-
168
- ## Checklist
169
-
170
- - [ ] `@lunora/hyperdrive/global` + the engine's driver installed.
171
- - [ ] Hyperdrive binding (`HYPERDRIVE`) created over the PlanetScale connection
172
- string, with a `localConnectionString` for dev.
173
- - [ ] Target tables marked `.global({ backend: "hyperdrive" })` (one backend per app).
174
- - [ ] `.hyperdriveGlobal({ engine, exec })` wired in the app composition.
175
- - [ ] `lunora codegen` run; `lunora dev` serves the tables reactively.
176
- - [ ] (Migrating) `lunora migrate d1-to-hyperdrive` run; row counts verified.
168
+ 1. **Installing `@lunora/hyperdrive/global` as a package name.** It's a subpath.
169
+ Install `@lunora/hyperdrive` and `@lunora/sql-store`.
170
+ 2. **Mixing D1 and Hyperdrive global tables.** Codegen rejects this.
171
+ 3. **Missing `FOUND_ROWS` on MySQL.** It causes spurious OCC conflicts on idempotent writes.
172
+ 4. **Stale-replica reads.** Point Hyperdrive at the primary.
173
+ 5. **Writing to the database directly.** Live queries won't see those writes. Route writes
174
+ through `ctx.db`.
@@ -1,157 +1,123 @@
1
1
  ---
2
2
  name: lunora-setup-mail
3
- 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.
3
+ 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.
4
4
  ---
5
5
 
6
6
  # Lunora Setup Mail
7
7
 
8
- Wire transactional email into a Lunora app using the `mail` registry item, which
9
- is built on `@lunora/mail` (a Cloudflare Email Workers transport with
10
- header-injection-safe address handling) and exposes a `sendEmail` `internalAction`
11
- plus a fire-and-forget `queueEmail` `internalAction` — server-only, because a
12
- client-callable general-purpose mailer is an open relay. In dev, every send is captured into the
13
- Studio Mail tab instead of going out.
8
+ The `mail` registry item wraps `@lunora/mail`, which validates addresses against
9
+ header injection before sending. It exposes two `internalAction`s, `sendEmail`
10
+ and `queueEmail`. Both are server-only on purpose: a mailer that lets the client
11
+ pick the recipient, subject and body is an open relay for phishing through your
12
+ verified domain. In dev, every send is captured into the Studio Mail tab and
13
+ nothing is delivered.
14
14
 
15
- ## When to Use
16
-
17
- - Sending app→user mail: invites, notifications, receipts.
18
- - Delivering verification / password-reset mail from `@lunora/auth`.
19
- - Using a React (`react-email`) template or a hosted provider (Resend).
20
-
21
- ## When Not to Use
22
-
23
- - The project has no Lunora backend yet — use `lunora-quickstart` first.
24
- - Mail is already installed and you just want to send — call
25
- `ctx.runAction(internal.mail.sendEmail, …)` from a server handler.
26
-
27
- ## Workflow
28
-
29
- 1. Add the `mail` item.
30
- 2. Configure the `SEND_EMAIL` binding (or a provider) and `MAIL_FROM`.
31
- 3. Regenerate types with `lunora codegen`.
32
- 4. Send mail from a server function; render a React template if needed.
15
+ If the project has no Lunora backend yet, start with `lunora-quickstart`.
33
16
 
34
17
  ## Step 1: Add the item
35
18
 
36
19
  ```bash
37
- lunora registry add mail
20
+ lunora add email # asks for the verified destination address
21
+ lunora add email --mail-to ops@example.com
22
+ lunora registry add mail # low-level: leaves the REPLACE_ME@example.com placeholder
38
23
  ```
39
24
 
40
- This:
25
+ Then run `pnpm install` and `lunora codegen`. The item does the following:
41
26
 
42
- 1. Adds `@lunora/mail` and `@lunora/server` to `package.json` (run
43
- `pnpm install` afterwards).
44
- 2. Copies `lunora/mail/index.ts` (the `sendEmail` / `queueEmail`
45
- **`internalAction`s**) into your project — it is **yours** to edit.
46
- 3. Adds a `send_email` binding (`SEND_EMAIL`, with a `destination_address`
47
- placeholder) to `wrangler.jsonc` and scaffolds `MAIL_FROM` (the default
48
- sender) into `.dev.vars`.
27
+ 1. Adds `@lunora/mail` and `@lunora/server`.
28
+ 2. Copies `lunora/mail/index.ts` into the project. The project owns this file. It defines `sendEmail`, which returns `{ id }`, and `queueEmail`, which returns `{ queued: true }`.
29
+ 3. Adds a `send_email` binding named `SEND_EMAIL` (with `destination_address`) to `wrangler.jsonc`.
30
+ 4. Writes `MAIL_FROM` to `.dev.vars`.
49
31
 
50
- ## Step 2: Configure delivery
32
+ Codegen emits the two functions as `internal.mail.sendEmail` and
33
+ `internal.mail.queueEmail`. They are not in the client-reachable `api`. A client
34
+ call to them returns `FUNCTION_NOT_FOUND`.
51
35
 
52
- | Name | Where | Notes |
53
- | ---------------- | ------------------------------------ | --------------------------------------------------------------------------------- |
54
- | `SEND_EMAIL` | `wrangler.jsonc` → `send_email[]` | Cloudflare Email Workers binding. Single-recipient; only verified destinations. |
55
- | `MAIL_FROM` | var (`.dev.vars` / `wrangler.jsonc`) | Default sender address. |
56
- | `RESEND_API_KEY` | secret (optional) | Use a hosted provider instead — pass `apiKey` to `createMailer` in `lunora/mail`. |
36
+ ## Step 2: Choose the delivery path
57
37
 
58
- For production with Cloudflare Email Workers, set up
59
- [Email Routing](https://developers.cloudflare.com/email-routing/): verify a
60
- destination address and replace the `REPLACE_ME@example.com` placeholder. Prefer
61
- Resend? Pass `apiKey` (or a custom `transport`) to `createMailer` in your copied
62
- `lunora/mail/index.ts`.
38
+ The copied file builds its mailer with `createMailerFromEnv`. That function
39
+ picks the transport in this order:
63
40
 
64
- In `lunora dev` (`WORKER_ENV=development`) the scaffold swaps in `@lunora/mail`'s
65
- **capture transport** automatically: every send — including `@lunora/auth`'s
66
- verification and forgot-password mail — is intercepted and surfaced in the
67
- **Studio Mail tab**. Nothing leaves your machine and no provider setup is needed.
41
+ | Condition | Transport |
42
+ | ----------------------------------------------------- | --------------------------------------------------- |
43
+ | `LUNORA_MAIL_CAPTURE=1`, or a dev environment (below) | **Capture** into the Studio Mail tab |
44
+ | `SEND_EMAIL` binding present | Cloudflare Email Workers |
45
+ | no binding, `RESEND_API_KEY` secret set | Resend |
46
+ | none of the above | throws on send (production never captures silently) |
68
47
 
69
- ## Step 3: Regenerate types
48
+ - **Cloudflare Email Workers.** The binding sends to a single recipient, and only to verified [Email Routing](https://developers.cloudflare.com/email-routing/) destinations. That fits app-to-operator mail. Verify the address and replace the placeholder.
49
+ - **Resend.** Use this to send to arbitrary users. Run `wrangler secret put RESEND_API_KEY` and remove the `send_email` binding. While the binding exists it takes precedence over Resend.
50
+ - **Capture.** Capture turns on when every env-name var (`WORKER_ENV`, `NODE_ENV`, …) looks like dev. `lunora dev` sets `WORKER_ENV=development`, so capture is on in dev. Capture writes to the root shard. If `SHARD` or `LUNORA_ADMIN_TOKEN` is missing, captured mail is discarded and a one-time warning is logged. To deliver for real from dev, set `LUNORA_MAIL_CAPTURE=0`.
70
51
 
71
- ```bash
72
- lunora codegen
73
- ```
74
-
75
- The functions surface in the generated **`internal`** (server-only) namespace as
76
- `internal.mail.sendEmail` and `internal.mail.queueEmail` — they are deliberately
77
- **not** in the client-reachable `api`.
52
+ `MAIL_FROM` is required on every path. It accepts `Name <addr@host>` or a bare
53
+ address.
78
54
 
79
- ## Step 4: Send mail
55
+ ## Step 3: Send from a server function
80
56
 
81
- ### From another function
82
-
83
- `sendEmail` is an **`internalAction`** (sending is non-transactional network
84
- I/O). From a mutation, schedule it as a follow-up so the request is not blocked:
57
+ `sendEmail` is an action because sending is non-transactional network I/O. From
58
+ a mutation, schedule it so the send runs only after the mutation commits:
85
59
 
86
60
  ```ts
87
- import { internalMutation, v } from "#lunora/_generated/server.js";
88
-
89
- import { internal } from "./_generated/internal";
61
+ import { internal } from "#lunora/_generated/internal.js";
62
+ import { mutation, v } from "#lunora/_generated/server.js";
90
63
 
91
- export const inviteUser = internalMutation.input({ email: v.string() }).mutation(async ({ ctx, args: { email } }) => {
92
- // ...authenticate the caller and persist the invite, then send the mail as a
93
- // follow-up action. The recipient is decided server-side — never forward a
94
- // client-chosen `to`/`from`/`html` straight through.
64
+ export const inviteMember = mutation.input({ teamId: v.id("teams"), email: v.string() }).mutation(async ({ ctx, args }) => {
65
+ // Authorize the caller and persist the invite first. The server decides
66
+ // the recipient and the content: never forward a client-chosen to/from/html.
95
67
  await ctx.scheduler.runAfter(0, internal.mail.sendEmail, {
96
- to: email,
68
+ to: args.email,
97
69
  subject: "You're invited",
98
70
  html: "<p>Click the link to join.</p>",
99
71
  });
100
72
  });
101
73
  ```
102
74
 
103
- ### Not from a client
75
+ Sending to an arbitrary `args.email` needs the Resend path in production: the
76
+ `SEND_EMAIL` binding only reaches its verified destination.
104
77
 
105
- There is no `client.action("mail/sendEmail", …)` path, and adding one is the
106
- mistake this item exists to prevent: a general-purpose mailer that lets the
107
- caller pick recipient, subject and body is an open relay for phishing through
108
- your verified domain. If you need a client-callable send, write a
109
- _purpose-specific_ public `action` that takes only safe business inputs (e.g.
110
- `{ orderId }`), checks `ctx.auth`/RBAC, derives the recipient server-side,
111
- rate-limits it (`@lunora/ratelimit`), and calls `internal.mail.sendEmail`.
78
+ From an action, call `await ctx.runAction(internal.mail.sendEmail, { … })`.
112
79
 
113
- ### With a React email template
80
+ If a client needs to trigger a send, write a public action for that one purpose.
81
+ It should take only business inputs (for example `{ orderId }`), check
82
+ `ctx.auth`, derive the recipient on the server, rate-limit the call
83
+ (`@lunora/ratelimit`), and then call `internal.mail.sendEmail`.
114
84
 
115
- React elements are not JSON-serializable across the RPC boundary, so the
116
- `sendEmail` args take `html` / `text`. To use a `react-email` template, render it
117
- where you call the mailer — edit `lunora/mail/index.ts` to pass `react` straight
118
- into `mailer.send`:
85
+ ### React templates
119
86
 
120
- ```ts
121
- import { createMailer } from "@lunora/mail";
122
- import { env } from "cloudflare:workers";
87
+ A React element can't be serialized across the RPC boundary, so render it to
88
+ `html` and `text` first. Rendering this way keeps dev capture working:
123
89
 
124
- import { WelcomeEmail } from "./emails/Welcome";
90
+ ```tsx
91
+ import { renderEmail } from "@lunora/mail"; // needs `react` installed
125
92
 
126
- await createMailer({ apiKey: env.RESEND_API_KEY as string, from: env.MAIL_FROM as string }).send({
127
- to: "alice@example.com",
128
- subject: "Welcome",
129
- react: <WelcomeEmail name="Alice" />,
130
- });
93
+ import { WelcomeEmail } from "./emails/welcome";
94
+
95
+ const { html, text } = await renderEmail(<WelcomeEmail name={name} />);
96
+ await ctx.runAction(internal.mail.sendEmail, { to, subject: "Welcome", html, text });
131
97
  ```
132
98
 
133
- ## Common Pitfalls
134
-
135
- 1. **Expecting prod email to "just work".** Dev captures into the Studio;
136
- production needs the `SEND_EMAIL` binding (a verified destination) or
137
- `RESEND_API_KEY`.
138
- 2. **Calling `sendEmail` from the client, or as a query/mutation.** It is an
139
- `internalAction` — invoke it via `ctx.runAction` / `ctx.scheduler.runAfter`
140
- from a server handler. A client `client.action("mail/sendEmail", …)` is not
141
- reachable and answers `FUNCTION_NOT_FOUND`.
142
- 3. **Using `queueEmail` without a Queue binding.** It requires a Cloudflare
143
- Queue producer binding; until you add one, `@lunora/mail` throws
144
- `` `queue` binding is required for mailer.queue() ``. The item does not add
145
- the Queue for you — see the `mail` README's "Queueing" section.
146
- 4. **Passing a React element through the action args.** Render it inside the
147
- mailer (`mailer.send({ react })`), not across the RPC boundary.
148
-
149
- ## Checklist
150
-
151
- - [ ] `lunora registry add mail` run, `pnpm install` done.
152
- - [ ] `SEND_EMAIL` binding configured (verified destination) or
153
- `RESEND_API_KEY` set; `MAIL_FROM` set.
154
- - [ ] `lunora codegen` run so `internal.mail.*` is generated.
155
- - [ ] Mail sent from a server function (`ctx.scheduler.runAfter` / `ctx.runAction`
156
- with `internal.mail.sendEmail`) — never from the client.
157
- - [ ] Verified the send appears in the Studio Mail tab in dev.
99
+ For the auth flows, `lunora add auth-emails` adds ready-made templates.
100
+
101
+ ### Queueing (optional)
102
+
103
+ `queueEmail` requires a Cloudflare Queue producer binding passed as
104
+ `createMailerFromEnv(env, { queue })` in `mailer()`, and the item doesn't add one. Without it, the call
105
+ throws `` `queue` binding is required for mailer.queue() ``, except under
106
+ capture, where it sends straight to the capture inbox so dev flows keep working.
107
+ A consumer builds its mailer with `createMailerFromEnv` too, drains the queue
108
+ with `consumeQueuedSend(mailer, message.body)` and then calls `message.ack()`
109
+ for each message. With a queue bound in dev, the message still goes through the
110
+ queue and the consumer's mailer captures it into the inbox. Queues deliver at least once: with
111
+ per-message acks a later throw retries only the unacknowledged messages, while
112
+ without them a single throw resends the whole batch. The full wiring is in the "Queueing"
113
+ section of the item README.
114
+
115
+ ## Testing and inbound
116
+
117
+ - **E2E.** `@lunora/mail/testing` exports `waitForMail({ baseUrl, adminToken, to, subjectMatch })` and `extractLink(mail, { match })`. Together they let you read the captured inbox and pull out a verification or reset link.
118
+ - **Receiving mail.** `@lunora/mail/inbound` provides `createInboundEmailHandler`, `parseInboundEmail` and `dispatchToLunoraFunction`. They route Email Routing messages into a Lunora function. Mount them with the generated builder's `.onEmail(...)`.
119
+
120
+ ## Verify
121
+
122
+ 1. Run `lunora dev` and trigger a send. With `SHARD` and `LUNORA_ADMIN_TOKEN` set, the message should appear in the Studio Mail tab; if either is missing, capture discards it and logs a warning.
123
+ 2. Before deploying, run `lunora doctor`. It flags the placeholder destination. Confirm that `MAIL_FROM` is set, and that either the binding uses a verified destination or `RESEND_API_KEY` is set with the binding removed.