@lunora/cli 1.0.0-alpha.362 → 1.0.0-alpha.364
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.
- package/dist/packem_chunks/handler6.mjs +2 -2
- package/dist/packem_chunks/runDeployCommand.mjs +1 -1
- package/dist/packem_chunks/runDevCommand.mjs +2 -2
- package/package.json +11 -11
- package/skills/lunora/SKILL.md +140 -164
- package/skills/lunora-create-package/SKILL.md +97 -85
- package/skills/lunora-deploy/SKILL.md +175 -144
- package/skills/lunora-functions/SKILL.md +186 -85
- package/skills/lunora-migration-helper/SKILL.md +146 -174
- package/skills/lunora-performance-audit/SKILL.md +135 -162
- package/skills/lunora-quickstart/SKILL.md +128 -195
- package/skills/lunora-realtime/SKILL.md +137 -130
- package/skills/lunora-setup-auth/SKILL.md +157 -124
- package/skills/lunora-setup-hyperdrive/SKILL.md +101 -104
- package/skills/lunora-setup-hyperdrive-global/SKILL.md +92 -94
- package/skills/lunora-setup-mail/SKILL.md +83 -117
- package/skills/lunora-setup-scheduler/SKILL.md +130 -99
- package/skills/lunora-setup-storage/SKILL.md +83 -149
|
@@ -1,67 +1,55 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: lunora-setup-hyperdrive-global
|
|
3
|
-
description:
|
|
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
|
|
9
|
-
through
|
|
10
|
-
|
|
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
|
-
>
|
|
15
|
-
> `
|
|
16
|
-
>
|
|
17
|
-
>
|
|
18
|
-
>
|
|
19
|
-
>
|
|
20
|
-
>
|
|
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
|
|
32
|
-
|
|
33
|
-
- Your global data is small and D1 is fine
|
|
34
|
-
|
|
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
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
|
34
|
+
## Step 1: Install
|
|
45
35
|
|
|
46
36
|
```bash
|
|
47
|
-
pnpm add @lunora/hyperdrive/
|
|
48
|
-
|
|
49
|
-
pnpm add
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
74
|
-
`lunora dev`
|
|
75
|
-
validator only checks that
|
|
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
|
-
|
|
79
|
-
|
|
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
|
|
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
|
-
|
|
93
|
-
|
|
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()`
|
|
82
|
+
## Step 4: Wire `.hyperdriveGlobal()`
|
|
97
83
|
|
|
98
|
-
Codegen emits a `.hyperdriveGlobal()` builder method.
|
|
99
|
-
|
|
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
|
-
|
|
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
|
|
116
|
-
guard needs matched-row counts,
|
|
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
|
-
|
|
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
|
|
128
|
+
lunora codegen
|
|
132
129
|
lunora dev
|
|
133
130
|
```
|
|
134
131
|
|
|
135
|
-
Tables
|
|
136
|
-
the dialect
|
|
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
|
-
|
|
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
|
-
|
|
141
|
-
|
|
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
|
|
146
|
-
--to-url https://new-
|
|
147
|
-
--tables settings,orders
|
|
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
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
are reported as conflicts
|
|
155
|
-
|
|
156
|
-
|
|
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. **
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
3. **
|
|
165
|
-
4. **
|
|
166
|
-
|
|
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
|
|
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
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
25
|
+
Then run `pnpm install` and `lunora codegen`. The item does the following:
|
|
41
26
|
|
|
42
|
-
1. Adds `@lunora/mail` and `@lunora/server
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
59
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
72
|
-
|
|
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
|
|
55
|
+
## Step 3: Send from a server function
|
|
80
56
|
|
|
81
|
-
|
|
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 {
|
|
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
|
|
92
|
-
//
|
|
93
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
121
|
-
|
|
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
|
-
|
|
90
|
+
```tsx
|
|
91
|
+
import { renderEmail } from "@lunora/mail"; // needs `react` installed
|
|
125
92
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
##
|
|
150
|
-
|
|
151
|
-
-
|
|
152
|
-
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
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.
|