void 0.20.0 → 0.20.1

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.
Files changed (52) hide show
  1. package/dist/{auth-cmd-CYVhSNFy.mjs → auth-cmd-CAH62yDU.mjs} +2 -2
  2. package/dist/{build-cmd-CkVD1uOh.mjs → build-cmd-CJvZvPQO.mjs} +1 -1
  3. package/dist/{cache-QcUfR-ff.mjs → cache-BlNeQjuP.mjs} +1 -1
  4. package/dist/{cancel-deploy-DsvWKFTe.mjs → cancel-deploy-CmlAZ9P6.mjs} +1 -1
  5. package/dist/cli/cli.mjs +164 -33
  6. package/dist/{client-CyCHWSO_.mjs → client-Clirrol3.mjs} +59 -0
  7. package/dist/{cloudflare-auth-DRkGe7-s.mjs → cloudflare-auth-B1QtTO1b.mjs} +51 -4
  8. package/dist/{cloudflare-cmd-D3ME2GAe.mjs → cloudflare-cmd-B6_OZx2V.mjs} +1 -1
  9. package/dist/{cloudflare-connect-BivMefMA.mjs → cloudflare-connect-j5D4hhrG.mjs} +1 -1
  10. package/dist/{connect-D9Yr-T5f.mjs → connect-C04Wdy_h.mjs} +4 -4
  11. package/dist/{create-project-DMV-csEm.mjs → create-project-ChGZ1DFd.mjs} +1 -1
  12. package/dist/{db-BxoUWL0F.mjs → db-D2d_mUsB.mjs} +31 -21
  13. package/dist/{delete-iJOqxBz1.mjs → delete-D8GigDk8.mjs} +1 -1
  14. package/dist/{deploy-CcDoeBIf.mjs → deploy-iXZ3F0N6.mjs} +20 -22
  15. package/dist/{domain-gu_iaHmn.mjs → domain-B1VmoSr0.mjs} +1 -1
  16. package/dist/email-uKyQYUVY.mjs +1016 -0
  17. package/dist/{env-DmU2To0C.mjs → env-BcQzYgoG.mjs} +1 -1
  18. package/dist/{gen-BKw6qHIg.mjs → gen-DI2YwdBM.mjs} +1 -1
  19. package/dist/generate-RTK8_kK1.mjs +47 -0
  20. package/dist/{github-cmd-DJS5Ab-H.mjs → github-cmd-xItS5Zwf.mjs} +1 -1
  21. package/dist/{inbound-CNKxb3FY.mjs → inbound-2d0zi2yS.mjs} +13 -1
  22. package/dist/{inbound-BJ70in1n.d.mts → inbound-afAcWeQ9.d.mts} +9 -0
  23. package/dist/index.mjs +20 -18
  24. package/dist/{init-Dx5cgKqK.mjs → init-BD-9THgn.mjs} +5 -5
  25. package/dist/{link-D_rm5sRH.mjs → link-RMdgjF1v.mjs} +2 -2
  26. package/dist/{list-M8XShti7.mjs → list-3F52R_yO.mjs} +1 -1
  27. package/dist/{local-d1-BE8KBbMy.mjs → local-d1-D2I6Ox5F.mjs} +1 -1
  28. package/dist/{login-C8-UuLnp.mjs → login-pV69H-ZO.mjs} +1 -1
  29. package/dist/{logs-DwFU7dMW.mjs → logs-DFHHD6wE.mjs} +1 -1
  30. package/dist/{node-BkaXcpAc.mjs → node-Dk3H2jmU.mjs} +1 -1
  31. package/dist/{platform-cmd-BEOVv1PN.mjs → platform-cmd-DxJ2FRwR.mjs} +1 -1
  32. package/dist/{platform-domain-BszUfiS8.mjs → platform-domain-ChvbJkdy.mjs} +2 -2
  33. package/dist/{platform-lifecycle-CjSr6xf0.mjs → platform-lifecycle-DN4MzJF_.mjs} +6 -6
  34. package/dist/{platform-management-W30iCaTL.mjs → platform-management-Db2PXw0B.mjs} +1 -1
  35. package/dist/{platform-recovery-pHHv4ZSg.mjs → platform-recovery-C_YO-tIs.mjs} +2 -2
  36. package/dist/{project-cmd-DCpk1cDt.mjs → project-cmd-Mo0V9yKS.mjs} +10 -10
  37. package/dist/{requests-Dn8Vheh1.mjs → requests-BcKOVpRg.mjs} +1 -1
  38. package/dist/{rollback-CtlPXEBi.mjs → rollback-Bx85-0xh.mjs} +1 -1
  39. package/dist/{runner-mysql-CgRFl3s6.mjs → runner-mysql-7BPUNGmL.mjs} +1 -1
  40. package/dist/{runner-pg-DCkWPsWS.mjs → runner-pg-BkEza-dX.mjs} +1 -1
  41. package/dist/runtime/email/testing.d.mts +1 -1
  42. package/dist/runtime/email/testing.mjs +1 -1
  43. package/dist/runtime/email.d.mts +1 -1
  44. package/dist/runtime/email.mjs +1 -1
  45. package/dist/{secret-BqTxGqki.mjs → secret-ByhJ9AMl.mjs} +1 -1
  46. package/dist/sqlite-validation-BzKMWnO4.mjs +25 -0
  47. package/dist/{validate-Bihr8WBi.mjs → validate-tBBN_dXH.mjs} +1 -0
  48. package/package.json +7 -7
  49. package/skills/void/docs/guide/email.md +34 -3
  50. package/skills/void/docs/guide/platform-development.md +29 -0
  51. package/skills/void/docs/reference/cli.md +51 -0
  52. package/dist/email-r6DHJyAB.mjs +0 -262
@@ -0,0 +1,25 @@
1
+ //#region src/migrations/sqlite-validation.ts
2
+ var SqliteMigrationError = class extends Error {
3
+ constructor(name, cause) {
4
+ super(`Migration ${name} could not be applied to a fresh SQLite database: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
5
+ this.name = "SqliteMigrationError";
6
+ }
7
+ };
8
+ /** Replay migration history without touching an application's database. Caller owns the result. */
9
+ async function openMigrationValidationDatabase(migrations) {
10
+ const { DatabaseSync } = await import("node:sqlite");
11
+ const db = new DatabaseSync(":memory:", { enableDoubleQuotedStringLiterals: false });
12
+ try {
13
+ for (const migration of migrations) try {
14
+ db.exec(migration.sql);
15
+ } catch (cause) {
16
+ throw new SqliteMigrationError(migration.name, cause);
17
+ }
18
+ return db;
19
+ } catch (error) {
20
+ db.close();
21
+ throw error;
22
+ }
23
+ }
24
+ //#endregion
25
+ export { openMigrationValidationDatabase as n, SqliteMigrationError as t };
@@ -365,6 +365,7 @@ function hasTopLevelKeyword(statement, keyword) {
365
365
  return false;
366
366
  }
367
367
  function hasUnboundedWrite(statement) {
368
+ if (/^(?:CREATE\s+(?:(?:TEMP|TEMPORARY|UNLOGGED)\s+)?|ALTER\s+)TABLE\b/i.test(statement)) return false;
368
369
  if (/\bDELETE\s+FROM\b/i.test(statement)) return !hasTopLevelKeyword(statement, "WHERE");
369
370
  if (/\bUPDATE\b[\s\S]*\bSET\b/i.test(statement)) return !hasTopLevelKeyword(statement, "WHERE");
370
371
  return false;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "void",
3
- "version": "0.20.0",
3
+ "version": "0.20.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/voidzero-dev/void.git",
@@ -313,11 +313,11 @@
313
313
  "@cloudflare/vite-plugin": "1.54.7",
314
314
  "@cloudflare/workers-types": "^4.20260702.1",
315
315
  "@napi-rs/keyring": "2.0.0",
316
- "@void/deploy-cloudflare": "0.20.0",
317
- "@void/deploy-core": "0.20.0",
318
- "@void/edge": "0.20.0",
319
- "@void/isr": "0.20.0",
320
- "@void/platform": "0.20.0",
316
+ "@void/deploy-cloudflare": "0.20.1",
317
+ "@void/deploy-core": "0.20.1",
318
+ "@void/edge": "0.20.1",
319
+ "@void/isr": "0.20.1",
320
+ "@void/platform": "0.20.1",
321
321
  "better-auth": "^1.7.3",
322
322
  "better-sqlite3": "^13.0.3",
323
323
  "blake3-jit": "^1.1.0",
@@ -363,7 +363,7 @@
363
363
  "zod": "^4.6.1"
364
364
  },
365
365
  "peerDependencies": {
366
- "@void/md": "0.20.0",
366
+ "@void/md": "0.20.1",
367
367
  "arktype": ">=2.0.0",
368
368
  "valibot": ">=1.0.0-beta.7",
369
369
  "vite": "^8.0.0",
@@ -66,9 +66,40 @@ The project owner's email (the GitHub address you signed up with) is added autom
66
66
  ::: warning When this is the right fit
67
67
  The shared sender is great for: ops alerts to the team, notifications to the project owner, reply-by-email flows on top of inbound, internal/app-internal mail.
68
68
 
69
- For SaaS sending to arbitrary end-users (every signup gets a welcome email), the per-recipient verification model doesn't fit. On [your own Cloudflare account](#your-own-cloudflare-account) with Workers Paid, Void onboards your mail domain for Email Sending, which lifts the verified-recipient gate. Otherwise use [Resend](https://resend.com), [Postmark](https://postmarkapp.com), or [SES](https://aws.amazon.com/ses/) directly — install their SDK and call it from your handler. We may formalize this with a provider abstraction later if there is demand; until then, calling the SDK directly is simple enough that the wrapper would not earn its keep.
69
+ For SaaS sending to arbitrary end-users (every signup gets a welcome email), the per-recipient verification model doesn't fit. On the platform, [registering your own domain](#your-own-domain-on-the-platform) lifts that gate for sends from that domain. On [your own Cloudflare account](#your-own-cloudflare-account) with Workers Paid, Void onboards your mail domain for Email Sending, which lifts the verified-recipient gate. Otherwise use [Resend](https://resend.com), [Postmark](https://postmarkapp.com), or [SES](https://aws.amazon.com/ses/) directly — install their SDK and call it from your handler. We may formalize this with a provider abstraction later if there is demand; until then, calling the SDK directly is simple enough that the wrapper would not earn its keep.
70
70
  :::
71
71
 
72
+ ## Your own domain on the platform
73
+
74
+ The shared sender lives on the platform's `mail.void.cloud` zone. To send — and receive — at a domain you own, register its Cloudflare zone with the project:
75
+
76
+ ```sh
77
+ void email domain add acme.com
78
+ ```
79
+
80
+ `add` needs one credential for the zone and offers two ways to grant it. The default opens Cloudflare's hosted consent page for an OAuth grant — one Allow click; the page names Wrangler, whose OAuth client Void borrows for it. Pressing Enter at any point, or any failure of the consent flow, switches to the fallback: a three-click template link that creates a scoped API token, which the CLI watches for on the clipboard or takes as a masked paste. Either credential is POSTed to the platform once and stored there, encrypted for the project — nothing is kept on your machine.
81
+
82
+ **Where the mail lives.** Void never enables routing over live mail: when `acme.com` already carries MX records, the CLI proposes `mail.acme.com` (`[change with --subdomain]`); the apex is used only when it carries no MX records. `--subdomain <label|host>` overrides the proposal outright.
83
+
84
+ **The pending state.** Enabling Email Routing on a subdomain is the one step with no API path, so when it is the step that remains, `add` prints it — Cloudflare dashboard → Email Routing → acme.com → Settings → Subdomains → add the subdomain — and the row sits at `pending`. Poll with:
85
+
86
+ ```sh
87
+ void email domain status acme.com
88
+ ```
89
+
90
+ Once public MX on the domain names Cloudflare, the row flips itself to `active` — no second command. `status` also re-probes the stored credential and the relay worker on every call, so it doubles as the drift report.
91
+
92
+ **When a row is not active.** `failed` names the step that failed; fix it and re-run `void email domain add acme.com` — adding again replaces the credential and retries, and the routing rules stay. `token_revoked` / `token_expired` mean the stored credential died at Cloudflare; re-run `add` with a fresh grant or token — rules and the relay stay.
93
+
94
+ Two upkeep verbs:
95
+
96
+ - `void email domain sync acme.com` redeploys the relay at the current version and rotates its secret — the answer when `status` reports the relay missing or drifted.
97
+ - `void email domain remove acme.com` deletes the platform row, the stored credential, and the relay secret. Cloudflare-side cleanup is best-effort; anything that could not be finished is named so you can delete it by hand.
98
+
99
+ Two rules bound registrations: one live email domain per Cloudflare zone (a second `add` on the same zone is refused until you `remove` the first), and one project per domain (a domain registered to another project is refused).
100
+
101
+ Sends from a registered domain skip the verified-recipient allowlist — the domain's own Email Sending onboarding replaces it — while platform quota and abuse controls still apply. Inbound needs nothing further: once the domain is `active`, mail to any address on it reaches your `email/` handlers.
102
+
72
103
  ## Options
73
104
 
74
105
  | Option | Type | Notes |
@@ -87,7 +118,7 @@ At most 100 recipients across `to`, `cc` and `bcc` per call. Each address is che
87
118
 
88
119
  `Address` accepts either a string (`"hello@acme.dev"` or `"Name <hello@acme.dev>"`) or an object (`{ email, name? }`). Display names with non-ASCII characters are RFC 2047 encoded automatically.
89
120
 
90
- On the platform the sender is pinned to your project. `from` must be your project's own platform address — `<project-slug>@mail.void.cloud` or `<project-slug>+<tag>@mail.void.cloud`, optionally with a display name (`Acme <acme+noreply@mail.void.cloud>`). Anything else is rejected with `INVALID_FROM`. Omit `from` and Void fills in `<project-slug>+noreply@mail.void.cloud` for you. Sending from your own domain on the platform is not supported yet.
121
+ On the platform the sender is pinned to your project. `from` must be your project's own platform address — `<project-slug>@mail.void.cloud` or `<project-slug>+<tag>@mail.void.cloud`, optionally with a display name (`Acme <acme+noreply@mail.void.cloud>`) — or any address on a domain registered with `void email domain add` (see [Your own domain on the platform](#your-own-domain-on-the-platform)). Anything else is rejected with `INVALID_FROM`. Omit `from` and Void fills in `<project-slug>+noreply@mail.void.cloud` for you.
91
122
 
92
123
  On your own Cloudflare account, `from` defaults to `email.from` from `void.json` and must be on a domain your account can send from; Cloudflare rejects any other sender and `sendEmail` reports it as `INVALID_FROM`.
93
124
 
@@ -383,7 +414,7 @@ never throws, while `replyEmail` throws when it cannot determine a sender.
383
414
 
384
415
  ### Configuring inbound delivery
385
416
 
386
- In production, inbound runs on one shared mail facility. The platform routes `<slug>+anything@<mail domain>` to your worker's `email()` export — **there is nothing to configure in the Cloudflare dashboard and no per-project DNS work**. Your handlers are live as soon as the deploy lands.
417
+ In production, inbound runs on one shared mail facility. The platform routes `<slug>+anything@<mail domain>` to your worker's `email()` export — **there is nothing to configure in the Cloudflare dashboard and no per-project DNS work**. Your handlers are live as soon as the deploy lands. A [registered custom domain](#your-own-domain-on-the-platform) lands on the same facility: once `void email domain add` reports it `active`, mail to any address on the domain reaches your `email/` handlers, with nothing further to configure.
387
418
 
388
419
  On your own Cloudflare account there is no shared facility: the deploy derives one Email Routing rule per handler and writes it into `wrangler.jsonc` for you — see [Your own Cloudflare account](#your-own-cloudflare-account).
389
420
 
@@ -232,4 +232,33 @@ Publishing requires both SDK CI and platform CI, including the platform unit and
232
232
  API integration suites. Release tags also run the Windows SDK checks; a passing
233
233
  SDK-only build cannot publish a changed control plane.
234
234
 
235
+ ### Retrying a Release
236
+
237
+ To retry a failed release without moving an existing tag, add `+retry.N` to a
238
+ new Git tag, with `N` starting at `1`. Keep the package versions unchanged:
239
+
240
+ | Git tag | Package version | npm channel |
241
+ | ------------------------ | --------------- | ----------- |
242
+ | `v0.21.0` | `0.21.0` | `latest` |
243
+ | `v0.21.0+retry.1` | `0.21.0` | `latest` |
244
+ | `v0.21.0-beta.1+retry.2` | `0.21.0-beta.1` | `beta` |
245
+
246
+ For example, when the packages are at `0.21.0`, commit the release fix and tag
247
+ that commit:
248
+
249
+ ```sh
250
+ git tag -a 'v0.21.0+retry.1' -m 'Retry 0.21.0 publication.'
251
+ git push origin 'refs/tags/v0.21.0+retry.1'
252
+ ```
253
+
254
+ The retry suffix belongs only in the Git tag, not in `package.json`. A `-1`
255
+ suffix is a distinct prerelease version, not a retry. Tag and package versions
256
+ are checked before dependency installation and the full CI jobs; retries still
257
+ run the normal release checks.
258
+
259
+ Retries publish only package versions that are still missing from npm. They
260
+ cannot replace an already-published version. If an earlier attempt partially
261
+ published the release and you changed its package contents, bump the version
262
+ instead of combining different contents under the same version.
263
+
235
264
  For implementation history, use the design archive at `platform/meta/design-docs/README.md`. Its proposals explain earlier decisions; the source and current guides define the supported behavior.
@@ -46,6 +46,7 @@ Use this page as a command reference. If you are setting up a project for the fi
46
46
  | `void email destinations` | List verified recipient addresses |
47
47
  | `void email allow <address>` | Add a recipient and send a verification email |
48
48
  | `void email disallow <address>` | Remove a recipient from the allowlist |
49
+ | `void email domain` | Send and receive at your own domain on a Cloudflare zone |
49
50
  | `void init` | Setup wizard for new or existing projects |
50
51
 
51
52
  ## Binary Invocation
@@ -742,6 +743,8 @@ Generate SQL migration files from schema changes.
742
743
 
743
744
  The command compares your current `db/schema.ts` or `db/schema/` modules against the last generated Drizzle snapshot and writes new migration artifacts under `db/migrations/`. When Void-managed auth is enabled, it also resolves the Better Auth schema in production mode and includes those tables automatically, including configured renames and plugin tables. This works for auth-only apps without an application schema. Review and commit the generated files before deploying.
744
745
 
746
+ For SQLite, Void checks that the migration history applies to a fresh database. If generation fails this check, the previous SQL, snapshots, and journal are restored. If an existing migration fails, repair that unapplied migration first: rerunning generation compares snapshots and does not repair existing SQL. This check does not verify that a migration preserves existing data; review table rebuilds and foreign-key actions carefully.
747
+
745
748
  ### `void db status`
746
749
 
747
750
  Show migration status. Displays which migrations are applied or pending locally, then uses the saved deployment target for remote status: the hosted API for Void projects, the pinned D1 database and its configured migration table for direct Cloudflare SQLite projects, or the shell `DATABASE_URL` for direct Cloudflare PostgreSQL/MySQL projects. If the remote credential or service is unavailable, local status is still shown.
@@ -1319,6 +1322,54 @@ void email disallow <address> [--project <name>]
1319
1322
 
1320
1323
  Remove one recipient from the project's destination list. Sends to that address are refused within about a minute: the platform updates the project's allowlist as part of the command, and the proxy re-reads it every 60 seconds. No deploy is involved.
1321
1324
 
1325
+ ### `void email domain`
1326
+
1327
+ ```
1328
+ void email domain <add|status|list|sync|remove> [<domain>] [--project <name>]
1329
+ ```
1330
+
1331
+ Send and receive at your own domain on a Cloudflare zone you own, registered to the project. Void platform only — on your own Cloudflare account the mail domain comes from `email.from` instead (see `void email setup`). The walkthrough is [Your own domain on the platform](../guide/email.md#your-own-domain-on-the-platform).
1332
+
1333
+ #### `void email domain add`
1334
+
1335
+ ```
1336
+ void email domain add <domain> [--subdomain <label|host>] [--project <name>]
1337
+ ```
1338
+
1339
+ Register the domain with the project. One credential is required, granted two ways: an OAuth grant from Cloudflare's hosted consent page (the page names Wrangler — Void borrows its OAuth client), or, as the fallback Enter switches to at any point, a scoped API token created from a three-click template link and picked up from the clipboard or a masked paste. The credential is POSTed once and stored on the platform, encrypted for the project — nothing is kept locally. Needs an interactive terminal. The CLI proposes `mail.<domain>` when the apex already carries MX records and allows the apex only when it carries none; `--subdomain` overrides the proposal. One live email domain per zone and one project per domain are enforced — a conflict is refused with a 409. Re-running `add` on a `failed`, `token_revoked`, or `token_expired` row replaces the credential and retries; the routing rules stay.
1340
+
1341
+ #### `void email domain status`
1342
+
1343
+ ```
1344
+ void email domain status <domain> [--project <name>]
1345
+ ```
1346
+
1347
+ Show one registered domain. The platform re-probes the stored credential and the relay worker on every call, so this doubles as the drift report. A `pending` row whose only remaining step is the dashboard's subdomain form (printed by `add`) self-clears to `active` once public MX on the domain names Cloudflare — which makes this command the poll for that one human step.
1348
+
1349
+ #### `void email domain list`
1350
+
1351
+ ```
1352
+ void email domain list [--project <name>]
1353
+ ```
1354
+
1355
+ List the project's registered email domains with their status and mode.
1356
+
1357
+ #### `void email domain sync`
1358
+
1359
+ ```
1360
+ void email domain sync <domain> [--project <name>]
1361
+ ```
1362
+
1363
+ Redeploy the domain's relay worker at the current version and rotate its secret — the fix when `status` reports the relay missing or drifted.
1364
+
1365
+ #### `void email domain remove`
1366
+
1367
+ ```
1368
+ void email domain remove <domain> [--project <name>]
1369
+ ```
1370
+
1371
+ Delete the registration: the platform row, the stored credential, and the relay secret. Cloudflare-side cleanup is best-effort; any step that fails is named (`failed_steps`) so you can finish it in the Cloudflare dashboard.
1372
+
1322
1373
  ### `void email status`
1323
1374
 
1324
1375
  ```
@@ -1,262 +0,0 @@
1
- import { D as log, F as isCancel, O as note, c as import_picocolors, w as confirm } from "./output-tFQLLj26.mjs";
2
- import { C as renderAddressMap, S as preflightEmail, a as isWranglerNonInteractiveOrCI, b as createNodeEmailDns, i as discoverRootWranglerConfig, o as loadProvisionModule, r as deriveWorkerName, s as resolveDeployTransport, t as createDeployWranglerRunner, u as runWranglerAuthToken, w as renderEmailChecklist, x as findEmailConfigConflicts, y as applyEmailSetup } from "./deploy-CcDoeBIf.mjs";
3
- import { n as readProjectPaths } from "./project-paths-SK8nMHPp.mjs";
4
- import { c as getToken, x as readProjectConfig } from "./auth-W9WII-mN.mjs";
5
- import { k as writeProvisionedWranglerConfig } from "./wrangler--imS8n0d.mjs";
6
- import { f as readConfig } from "./config-BQFq7QvD.mjs";
7
- import { a as scanEmailHandlersSync } from "./email-Ce6SQq-i.mjs";
8
- import { n as PlatformClient } from "./client-CyCHWSO_.mjs";
9
- import { parseRootWranglerConfig, readWranglerTopLevelString } from "@void/deploy-cloudflare";
10
- //#region src/cli/email.ts
11
- async function runEmailCommand(root, args) {
12
- if (args.platform === "cloudflare") return runEmailCloudflareCommand(root, args);
13
- if (args.subcommand === "setup") {
14
- log.error("email: `void email setup` sets email up on your OWN Cloudflare account — run it with `--platform cloudflare`. The Void platform needs no setup: deploy, and `void email usage` shows your address.");
15
- process.exit(1);
16
- }
17
- if (args.subcommand === "status") {
18
- log.error("email: `void email status` is available with `--platform cloudflare` today. On the Void platform use `void email usage` and `void email destinations`.");
19
- process.exit(1);
20
- }
21
- const token = getToken(root);
22
- if (!token) {
23
- log.error("Not logged in. Run `void auth login` first.");
24
- process.exit(1);
25
- }
26
- const client = new PlatformClient(token, { root });
27
- const requestedSlug = args.projectSlug ?? process.env.VOID_PROJECT?.trim();
28
- let projectId;
29
- if (requestedSlug) {
30
- const project = (await client.listProjects()).find((p) => p.slug === requestedSlug);
31
- if (!project) {
32
- log.error(`project: No project found with slug '${requestedSlug}'.`);
33
- process.exit(1);
34
- }
35
- projectId = project.id;
36
- } else {
37
- const config = readProjectConfig(root);
38
- if (!config) {
39
- log.error("No linked project. Run `void project link` or pass --project <name>.");
40
- process.exit(1);
41
- }
42
- projectId = config.projectId;
43
- }
44
- switch (args.subcommand) {
45
- case "usage": return emailUsage(client, projectId);
46
- case "logs": return emailLogs(client, projectId, args.limit);
47
- case "destinations": return emailDestinations(client, projectId);
48
- case "allow": return emailAllow(client, projectId, args.address);
49
- case "disallow": return emailDisallow(client, projectId, args.address);
50
- }
51
- }
52
- async function emailUsage(client, projectId) {
53
- const usage = await client.getEmailUsage(projectId);
54
- const status = usage.suspended ? import_picocolors.default.red("SUSPENDED") : import_picocolors.default.green("active");
55
- const lines = [
56
- `Month: ${usage.month}`,
57
- `Status: ${status}`,
58
- `Outbound: ${usage.sent} / ${usage.limit} (${usage.remaining} remaining)`,
59
- `Inbound: ${usage.received}`
60
- ];
61
- note(lines.join("\n"), "Email Usage");
62
- }
63
- async function emailDestinations(client, projectId) {
64
- const rows = await client.listEmailDestinations(projectId);
65
- if (rows.length === 0) {
66
- log.info("No verified destinations. Run `void email allow <address>` to add one — Cloudflare will email the recipient with a verification link.");
67
- return;
68
- }
69
- const lines = rows.map((d) => {
70
- const status = d.status === "verified" ? import_picocolors.default.green("verified") : d.status === "pending" ? import_picocolors.default.yellow("pending") : import_picocolors.default.red("failed");
71
- return ` ${d.address.padEnd(40)} ${status}`;
72
- });
73
- note(lines.join("\n"), "Email Destinations");
74
- }
75
- /**
76
- * `void email allow <address>` — exported for the unit test; the command
77
- * entry (`runEmailCommand`) resolves the client and project first.
78
- */
79
- async function emailAllow(client, projectId, address) {
80
- const dest = await client.addEmailDestination(projectId, address);
81
- const sent = dest.verificationSent !== false && (dest.created || dest.platformVerificationPending);
82
- if (dest.status === "verified") log.success(`${dest.address} is already verified.`);
83
- else if (sent) {
84
- log.success(`Verification email sent to ${dest.address}.`);
85
- log.info("They click the link in their inbox — no Void or Cloudflare account needed. `void email destinations` to poll status.");
86
- } else if (dest.created) log.warn(`${dest.address} is on this project's list now, but Cloudflare already had the address pending (added by another project, or by hand), so no new verification email was sent — the link it mailed when the address was first added is the one to click. ${freshLinkHint(dest.address)}`);
87
- else log.info(`${dest.address} is still pending — Cloudflare emailed them a verification link when the address was first added, and a repeat add sends nothing new. ${freshLinkHint(dest.address)}`);
88
- }
89
- /**
90
- * Cloudflare has no API to resend its verification mail; the only fresh link
91
- * is a fresh registration, and the platform deletes the Cloudflare-side
92
- * address only once no project still lists it.
93
- */
94
- function freshLinkHint(address) {
95
- return `For a fresh link, remove the address from every project that lists it (\`void email disallow ${address}\`), then \`void email allow ${address}\` — Cloudflare mails a new link only when it registers the address anew.`;
96
- }
97
- /**
98
- * `void email disallow <address>` — exported for the unit test; the command
99
- * entry (`runEmailCommand`) resolves the client and project first.
100
- *
101
- * No deploy is involved: the api tightens the proxy's `email-allowlist`
102
- * KV mirror inside the DELETE (and answers 503 if that write fails), and
103
- * the proxy re-reads the mirror with a 60s `cacheTtl` on both the send
104
- * path and inbound forward/reply — so the address is refused once that
105
- * per-colo cache turns over.
106
- */
107
- async function emailDisallow(client, projectId, address) {
108
- const dest = (await client.listEmailDestinations(projectId)).find((d) => d.address.toLowerCase() === address.toLowerCase());
109
- if (!dest) {
110
- log.error(`destination: '${address}' is not in the project's allowlist.`);
111
- process.exit(1);
112
- }
113
- await client.removeEmailDestination(projectId, dest.id);
114
- log.success(`Removed ${dest.address}. Sends to it are refused within about a minute.`);
115
- }
116
- /**
117
- * `void email logs` — exported for the unit test; the command entry
118
- * (`runEmailCommand`) resolves the client and project first.
119
- */
120
- async function emailLogs(client, projectId, limit) {
121
- const result = await client.getEmailLogs(projectId, limit);
122
- if (result.pending) {
123
- log.info(result.note ?? "Persistent log storage is not yet wired up.");
124
- log.info("Email activity logs are not available yet. Console output from your email handler appears in `void project logs`.");
125
- return;
126
- }
127
- if (result.logs.length === 0) {
128
- log.info("No email activity recorded.");
129
- return;
130
- }
131
- const lines = result.logs.map((entry) => {
132
- const ts = new Date(entry.timestamp).toISOString();
133
- const dir = entry.direction === "sent" ? "→" : "←";
134
- const subject = entry.subject ? ` "${entry.subject}"` : "";
135
- return `${ts} ${dir} ${entry.from} → ${entry.to} ${entry.status}${subject}`;
136
- });
137
- note(lines.join("\n"), "Email Logs");
138
- }
139
- /**
140
- * `void email setup --platform cloudflare` and `void email status --platform cloudflare`.
141
- *
142
- * `setup` is the deploy's step 3b-ii on its own: preflight → checklist → the one Enter →
143
- * apply → write the root wrangler.jsonc. It exists for CI: a non-interactive deploy that
144
- * finds email not ready prints the checklist and says to run this once, commit
145
- * wrangler.jsonc, and redeploy. Rules and the catch-all stay wrangler's job — the next
146
- * `void deploy --platform cloudflare` applies the plan from the written `addresses`. It
147
- * is also the ONLY place a sending onboarding the committed binding remembers as refused
148
- * (Workers Free) is retried — on its own prompt, after a Workers Paid upgrade; the deploy
149
- * reads that row as settled and never retries it.
150
- *
151
- * `status` is the preflight alone, printed, exit 1 when not ready.
152
- */
153
- async function runEmailCloudflareCommand(root, args, deps = {}) {
154
- const exit = deps.exit ?? ((code) => process.exit(code));
155
- const print = deps.print ?? ((block) => log.message(block));
156
- const fail = (message) => {
157
- log.error(message);
158
- return exit(1);
159
- };
160
- const from = readConfig(root).email?.from;
161
- if (!from) return fail("email: no sender configured. Add \"email\": { \"from\": \"you@mail.acme.com\" } to void.json — its host is the domain email is set up on.");
162
- const paths = readProjectPaths(root);
163
- const handlers = scanEmailHandlersSync(paths.sourceRoot);
164
- const configPath = discoverRootWranglerConfig(root);
165
- const rootConfig = parseRootWranglerConfig(configPath);
166
- await loadProvisionModule();
167
- let apiBaseUrl;
168
- try {
169
- apiBaseUrl = resolveDeployTransport(root, rootConfig);
170
- } catch (err) {
171
- return fail(`email: ${err instanceof Error ? err.message : String(err)}`);
172
- }
173
- const accountId = readWranglerTopLevelString(rootConfig, "account_id") ?? (process.env.CLOUDFLARE_ACCOUNT_ID?.trim() || void 0);
174
- if (!accountId) return fail([
175
- "email: no Cloudflare account is pinned.",
176
- "Set `account_id` in your root wrangler.jsonc, or export CLOUDFLARE_ACCOUNT_ID —",
177
- "the same pin `void deploy --platform cloudflare` needs."
178
- ].join("\n"));
179
- const workerName = readWranglerTopLevelString(rootConfig, "name") ?? deriveWorkerName(root);
180
- const runWrangler = deps.runWrangler ?? createDeployWranglerRunner(root, accountId, apiBaseUrl);
181
- const getSessionBearer = deps.getSessionBearer ?? (() => runWranglerAuthToken(runWrangler, configPath));
182
- const sendEmail = rootConfig.send_email;
183
- const inputs = {
184
- handlers,
185
- from,
186
- workerName,
187
- accountId,
188
- existingSendEmail: Array.isArray(sendEmail) ? sendEmail : null,
189
- existingAddresses: rootConfig.addresses,
190
- existingVars: rootConfig.vars && typeof rootConfig.vars === "object" && !Array.isArray(rootConfig.vars) ? rootConfig.vars : null,
191
- configConflicts: findEmailConfigConflicts(rootConfig),
192
- wranglerArgs: [
193
- "-c",
194
- configPath,
195
- "--env="
196
- ],
197
- apiBaseUrl,
198
- runWrangler,
199
- getSessionBearer,
200
- fetchImpl: deps.fetchImpl ?? fetch,
201
- dns: deps.dns ?? await createNodeEmailDns()
202
- };
203
- let pre;
204
- try {
205
- pre = await preflightEmail(inputs);
206
- } catch (err) {
207
- return fail(`email: ${err instanceof Error ? err.message : String(err)}`);
208
- }
209
- print(renderEmailChecklist(pre));
210
- if (args.subcommand === "status") {
211
- print(renderAddressMap({
212
- from,
213
- routes: pre.derived.routes,
214
- addresses: pre.rules.mine,
215
- sendingReady: pre.sending.state === "ready",
216
- pendingLabel: "(no rule yet)"
217
- }));
218
- if (pre.ready) {
219
- log.success(`Email is set up on ${pre.domain}.`);
220
- return;
221
- }
222
- log.warn(`Email is not set up on ${pre.domain} yet — run \`void email setup --platform cloudflare\`.`);
223
- return exit(1);
224
- }
225
- if (pre.blockers.some((b) => b.scope === "all")) return fail("email: nothing was changed — fix the ✘ rows above and rerun.");
226
- if (!pre.ready || pre.sendingRetryable) {
227
- if (!(deps.isInteractive ?? !isWranglerNonInteractiveOrCI())) return fail("email: `void email setup` changes your Cloudflare account, so it needs an interactive terminal. Run it locally once, commit wrangler.jsonc, then redeploy.");
228
- if (!await (deps.confirm ?? (async (message) => {
229
- const answer = await confirm({
230
- message,
231
- initialValue: true
232
- });
233
- return !isCancel(answer) && answer === true;
234
- }))(pre.ready ? `Onboard ${pre.domain} for Email Sending? Inbound already works; onboarding needs Workers Paid.` : `Set up email on ${pre.domain}?`)) {
235
- log.info("Nothing changed.");
236
- return;
237
- }
238
- }
239
- const out = await applyEmailSetup(pre, inputs);
240
- if (out.lines.length > 0) print(out.lines.join("\n"));
241
- try {
242
- writeProvisionedWranglerConfig(root, out.patch, { configPath });
243
- } catch (err) {
244
- return fail(`email: could not update ${configPath} — ${err instanceof Error ? err.message : String(err)}`);
245
- }
246
- print(renderAddressMap({
247
- from,
248
- routes: pre.derived.routes,
249
- addresses: out.patch.addresses ?? [],
250
- sendingReady: out.sendingReady,
251
- pendingLabel: "(no rule yet)"
252
- }));
253
- if (!out.inboundReady) {
254
- const missing = !out.routingReady ? `Email Routing on ${pre.domain} is not ready` : !out.subaddressingReady ? `subaddressing on ${pre.apex} is still off (support+anything@ would not reach support@)` : "`addresses` was withheld";
255
- log.warn(`Email is not set up on ${pre.domain} yet — ${missing}; no routing rule is created until it is. Finish the step above, then rerun \`void email setup --platform cloudflare\`.`);
256
- return exit(1);
257
- }
258
- if ((out.patch.addresses ?? []).length > 0) log.info("Routing rules are created by the next `void deploy --platform cloudflare`, which applies the Email Routing plan from `addresses`.");
259
- log.success(`Email set up on ${pre.domain}.`);
260
- }
261
- //#endregion
262
- export { runEmailCommand };