void 0.20.1 → 0.20.3

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 (109) hide show
  1. package/README.md +5 -1
  2. package/dist/{auth-W9WII-mN.mjs → auth-DPl6kck4.mjs} +46 -26
  3. package/dist/{auth-cmd-CAH62yDU.mjs → auth-cmd-CzwquNiP.mjs} +5 -4
  4. package/dist/auth-link-ElDTgF7j.mjs +28 -0
  5. package/dist/{build-cmd-CJvZvPQO.mjs → build-cmd-BpJe6boP.mjs} +3 -3
  6. package/dist/{cache-BlNeQjuP.mjs → cache-D98YTqeE.mjs} +3 -3
  7. package/dist/{cancel-deploy-CmlAZ9P6.mjs → cancel-deploy-CPbEQLMG.mjs} +3 -3
  8. package/dist/cf-access-DRsQRe6k.mjs +75 -0
  9. package/dist/cli/cli.mjs +309 -1958
  10. package/dist/cli/env-schema-probe.mjs +11 -2
  11. package/dist/client-BQBrZoCX.mjs +989 -0
  12. package/dist/{cloudflare-auth-B1QtTO1b.mjs → cloudflare-auth-6M5llVPC.mjs} +2 -2
  13. package/dist/{cloudflare-cmd-B6_OZx2V.mjs → cloudflare-cmd-4RPGN3KB.mjs} +2 -2
  14. package/dist/{cloudflare-connect-j5D4hhrG.mjs → cloudflare-connect-t1UU5svD.mjs} +2 -2
  15. package/dist/{cloudflare-operations-CPTpRW6d.mjs → cloudflare-operations-BzWnlC1_.mjs} +1 -1
  16. package/dist/{config-BQFq7QvD.mjs → config-uNGuFsI2.mjs} +1 -1
  17. package/dist/{connect-C04Wdy_h.mjs → connect-WCQZ_u3m.mjs} +6 -6
  18. package/dist/{create-project-ChGZ1DFd.mjs → create-project-D0oXA090.mjs} +7 -7
  19. package/dist/{db-D2d_mUsB.mjs → db-DJ-9qs3S.mjs} +47 -30
  20. package/dist/{delete-D8GigDk8.mjs → delete-BZ4-WaGm.mjs} +3 -3
  21. package/dist/{deploy-iXZ3F0N6.mjs → deploy-BAhhcg5q.mjs} +135 -117
  22. package/dist/{dev-inbox-DkgRWLkW.mjs → dev-inbox-P0u4tM8Y.mjs} +1 -1
  23. package/dist/{domain-B1VmoSr0.mjs → domain-BxAyhxXN.mjs} +4 -4
  24. package/dist/email-Bj7Cvdwp.mjs +795 -0
  25. package/dist/{env-D4Emu-M_.mjs → env-DBKmK4vc.mjs} +1 -0
  26. package/dist/{env-BcQzYgoG.mjs → env-DP_EErve.mjs} +5 -5
  27. package/dist/{env-validation-ENpMy6Ez.mjs → env-validation-CF6KvTRf.mjs} +3 -1
  28. package/dist/{gen-DI2YwdBM.mjs → gen-B_wPnVTK.mjs} +2 -2
  29. package/dist/{github-cmd-xItS5Zwf.mjs → github-cmd-C-z_xRrQ.mjs} +15 -21
  30. package/dist/{headers-D8QfRX9Y.mjs → headers-BAHwgHdW.mjs} +1 -1
  31. package/dist/help-CwOX-zmI.mjs +2216 -0
  32. package/dist/{inbound-afAcWeQ9.d.mts → inbound-CH5Mksyy.d.mts} +34 -48
  33. package/dist/{inbound-2d0zi2yS.mjs → inbound-aVHEUhKo.mjs} +130 -100
  34. package/dist/index.mjs +54 -17
  35. package/dist/{init-BD-9THgn.mjs → init-BGktCXgA.mjs} +11 -11
  36. package/dist/{link-RMdgjF1v.mjs → link-D2kbqhWb.mjs} +4 -4
  37. package/dist/{list-3F52R_yO.mjs → list-CvkK_G7k.mjs} +4 -4
  38. package/dist/{login-pV69H-ZO.mjs → login-WIjNc77c.mjs} +28 -10
  39. package/dist/{logs-DFHHD6wE.mjs → logs-dLUFCapG.mjs} +4 -4
  40. package/dist/{mime-BJD7d_qL.mjs → mime-D5Nmdzf7.mjs} +23 -9
  41. package/dist/{node-Dk3H2jmU.mjs → node-Ez5KW5rn.mjs} +2 -2
  42. package/dist/operator-auth-B3e08unv.mjs +52 -0
  43. package/dist/operator-client-LUZnlnYk.mjs +82 -0
  44. package/dist/{operator-cmd-DYWRbWUA.mjs → operator-cmd-CKJ7xIRs.mjs} +35 -55
  45. package/dist/{output-tFQLLj26.mjs → output-B0cfNSx5.mjs} +316 -2
  46. package/dist/pages/index.mjs +2 -2
  47. package/dist/platform-auth-config-CdVWRRJr.mjs +368 -0
  48. package/dist/platform-auth-protection-Drl0qhrn.mjs +219 -0
  49. package/dist/platform-auth-recovery-CmKEWpDo.mjs +310 -0
  50. package/dist/{platform-cmd-DxJ2FRwR.mjs → platform-cmd-5q_k56xS.mjs} +16 -6
  51. package/dist/{platform-domain-ChvbJkdy.mjs → platform-domain-4GiDlcqx.mjs} +4 -4
  52. package/dist/{platform-lifecycle-DN4MzJF_.mjs → platform-lifecycle-R9xAxvtG.mjs} +1716 -204
  53. package/dist/{platform-management-Db2PXw0B.mjs → platform-management-BfWsXHEW.mjs} +35 -7
  54. package/dist/{platform-recovery-C_YO-tIs.mjs → platform-recovery-CbK-I1FB.mjs} +6 -5
  55. package/dist/{prepare-CBetXvsN.mjs → prepare-CtDJjoOj.mjs} +2 -2
  56. package/dist/{prepare-BfJvFUtJ.mjs → prepare-blNRQvQl.mjs} +2 -2
  57. package/dist/prerender-render.d.mts +11 -0
  58. package/dist/prerender-render.mjs +111 -0
  59. package/dist/{project-cmd-Mo0V9yKS.mjs → project-cmd-CTdmnzvc.mjs} +32 -14
  60. package/dist/project-team-CGxsQe3_.mjs +132 -0
  61. package/dist/project-token-Cirx7uwZ.mjs +75 -0
  62. package/dist/{provision-Blnstcm2.mjs → provision-CSJOjjQk.mjs} +2 -0
  63. package/dist/{requests-BcKOVpRg.mjs → requests-4Nq59hOr.mjs} +3 -3
  64. package/dist/{rollback-Bx85-0xh.mjs → rollback-Dr7u0Ljx.mjs} +4 -4
  65. package/dist/runtime/ai.mjs +3 -2
  66. package/dist/runtime/email/testing.d.mts +1 -1
  67. package/dist/runtime/email/testing.mjs +3 -3
  68. package/dist/runtime/email-protocol.d.mts +15 -0
  69. package/dist/runtime/email-protocol.mjs +70 -0
  70. package/dist/runtime/email.d.mts +2 -2
  71. package/dist/runtime/email.mjs +189 -96
  72. package/dist/runtime/remote/index.mjs +54 -11
  73. package/dist/runtime/sandbox.d.mts +4 -56
  74. package/dist/runtime/sandbox.mjs +81 -220
  75. package/dist/{secret-ByhJ9AMl.mjs → secret-AcPi-FoA.mjs} +5 -5
  76. package/dist/{skills-Q46GZMO-.mjs → skills-C0RvGjeE.mjs} +1 -1
  77. package/dist/{subcommand-prompt-WfySCQ7S.mjs → subcommand-prompt-Bmyn5Rlc.mjs} +1 -1
  78. package/package.json +12 -7
  79. package/skills/void/SKILL.md +35 -4
  80. package/skills/void/docs/guide/deployment.md +16 -14
  81. package/skills/void/docs/guide/email.md +114 -120
  82. package/skills/void/docs/guide/platform/administration/access.md +163 -0
  83. package/skills/void/docs/guide/platform/administration/email.md +121 -0
  84. package/skills/void/docs/guide/platform/administration/operations.md +97 -0
  85. package/skills/void/docs/guide/platform/administration/projects.md +54 -0
  86. package/skills/void/docs/guide/platform/development/local.md +119 -0
  87. package/skills/void/docs/guide/platform/development/runtime.md +124 -0
  88. package/skills/void/docs/guide/platform/development/schema-ci.md +95 -0
  89. package/skills/void/docs/guide/platform/installation/ci.md +55 -0
  90. package/skills/void/docs/guide/platform/installation/credentials.md +80 -0
  91. package/skills/void/docs/guide/platform/installation/domains.md +68 -0
  92. package/skills/void/docs/guide/platform/installation/first-deployment.md +82 -0
  93. package/skills/void/docs/guide/platform/installation/maintenance.md +137 -0
  94. package/skills/void/docs/guide/platform/installation/prerequisites.md +86 -0
  95. package/skills/void/docs/guide/platform/installation/setup.md +169 -0
  96. package/skills/void/docs/guide/platform/installation/uninstall.md +54 -0
  97. package/skills/void/docs/guide/platform-administration.md +6 -202
  98. package/skills/void/docs/guide/platform-development.md +5 -254
  99. package/skills/void/docs/guide/project-collaboration.md +94 -0
  100. package/skills/void/docs/guide/sandboxes.md +9 -24
  101. package/skills/void/docs/guide/self-hosted-platform.md +13 -542
  102. package/skills/void/docs/reference/api.md +34 -34
  103. package/skills/void/docs/reference/cli.md +276 -31
  104. package/skills/void/docs/reference/config.md +1 -1
  105. package/skills/void/docs/reference/resource-inference.md +10 -10
  106. package/dist/cf-access-AJ1ehiFR.mjs +0 -42
  107. package/dist/cf-access-DsSsZUPr.mjs +0 -67
  108. package/dist/client-Clirrol3.mjs +0 -705
  109. package/dist/email-uKyQYUVY.mjs +0 -1016
@@ -23,7 +23,7 @@ read permission; `void platform install --plan` verifies coverage. Use the
23
23
  self-hosted platform guide for certificate setup rather than enabling a paid
24
24
  product without an explicit request.
25
25
 
26
- For first-time platform setup, follow `docs/guide/self-hosted-platform.md` in order and introduce credentials when the user reaches that step. Recommend a domain; offer explicit `void platform install --workers-dev` for testing before one is ready. PostgreSQL, GitHub OAuth registration, runtime/R2 credentials, and saved signing/encryption keys are still required. Domain-free installation skips zone/DNS/certificate operations and can use browser login. `void platform domain set <domain>` later performs resumable DNS/HTTPS setup while preserving existing test URLs and the API origin. The command checks the deployed runtime token's cache-purge permission for the new zone; it does not ask users to retrieve that token. Prefer interactive prompts for secrets and keep CI variables in the CI workflow. Void creates the platform infrastructure and database tables.
26
+ For first-time platform setup, follow the installation pages linked from `docs/guide/self-hosted-platform.md` in order and introduce credentials when the user reaches that step. Recommend a domain; offer explicit `void platform install --workers-dev` for testing before one is ready. PostgreSQL, credentials for the selected login methods, runtime/R2 credentials, and saved signing/encryption keys are required. GitHub is the default and is optional. `--auth-config` accepts nonsecret provider configuration with environment secret references; `--plan` does not read those secrets. Configurable installations finish first-administrator setup using a one-time code and browser identity confirmation. Domain-free installation skips zone/DNS/certificate operations and can use browser login. `void platform domain set <domain>` later performs resumable DNS/HTTPS setup while preserving existing test URLs and the API origin. The command checks the deployed runtime token's cache-purge permission for the new zone; it does not ask users to retrieve that token. Prefer interactive prompts for secrets and keep CI variables in the CI workflow. Void creates the platform infrastructure and database tables.
27
27
 
28
28
  For an existing Cloudflare site, run `void deploy` with its root `wrangler.jsonc` or `wrangler.json`. An unlinked project gets a prompt to link and deploy using the existing Worker and resources. Accepting verifies and saves the destination, then continues deployment. Keep new application resources, migrations, auth setup, and secret changes separate from this first handoff; ISR has its own explicit cache choice. The active Worker Version must be the latest upload so inherited secrets have an unambiguous source. Failed builds retain the link for retry. Read `docs/integrations/cloudflare.md` for the supported configuration and rollback behavior.
29
29
 
@@ -32,22 +32,51 @@ When linking reports missing inferred resources, check the listed bindings and i
32
32
  For Cloudflare upload failures, use the detailed error message printed by the CLI and saved in the deploy log. A numeric error code alone may not identify the cause.
33
33
 
34
34
  Platform administration uses `void platform auth login` and the nested
35
- `user`, `project`, `deployment`, `build`, `signup`, `invitation`, `system`, and
35
+ `user`, `project`, `deployment`, `build`, `signup`, `invitation`, `email`, `system`, and
36
36
  hosted-only `worker` groups. Open the operator section of `docs/reference/cli.md`
37
37
  before running these commands. Use `--json` for structured reads and `--plan`
38
38
  to inspect mutations; approved noninteractive mutations require `--yes`.
39
39
  The last active administrator cannot be deleted or suspended. If an allowed
40
40
  administrator removal reports partial cleanup, access remains revoked; use a
41
41
  remaining administrator to inspect the result and retry.
42
+ Use `void platform config auth` to configure login methods; `platform auth`
43
+ manages your administrator session. Test a pending configuration before enabling
44
+ it, explicitly link identities when switching methods, and verify a linked
45
+ alternative before disabling a method. Disabling revokes its human sessions,
46
+ including human tokens used in CI, while scoped deployment tokens remain valid.
47
+ Create CI credentials with `void project token create`; they are bound to one
48
+ project, expire within 90 days, and support explicit renewal and revocation.
49
+ Cloudflare Access service credentials pass only the perimeter and never renew or
50
+ elevate a human or operator token.
51
+ For scripted configuration, use nonsecret JSON with `--file` and read secrets
52
+ with `--client-secret-env`; never put client secrets in command arguments.
53
+ Use `void platform config auth admission` to select company-approved automatic
54
+ signup when the organization's identity policy already determines eligibility;
55
+ individual invitations remain optional. The same controls are available under
56
+ Settings in the administrator UI.
57
+ Access login and platform protection are independent. The installer can create
58
+ dedicated Access applications or connect existing ones, including a separate
59
+ identity account. Protection setup verifies API/proxy coverage and uses scoped
60
+ service credentials for installation and CI. Use `platform config auth protection`
61
+ to show, enable, or disable protection; retain the company gate when probes fail.
62
+ Users link another enabled method with `void auth link [connection-id]` after
63
+ recent sign-in and explicit browser identity confirmation. For expired first-admin
64
+ codes, resume installation or repair completed provisioning. Lost-provider recovery
65
+ uses `platform config auth recover` with installation ownership, original recovery
66
+ keys, an existing administrator ID, and a real provider login; never reopen signup.
42
67
  Operator credentials are separate from application deployment credentials and
43
68
  ignore repository platform selections. `VOID_OPERATOR_TOKEN` requires an
44
69
  explicit `VOID_API_URL` or `--connection`; credentials must never be written to
45
70
  project files. `signup open` allows public signup and `signup restrict` enforces
46
- the allowlist. Inspect `system events` and the target object after an ambiguous
71
+ the allowlist. For OIDC identities without verified email, use `signup allow identity
72
+ <connection-id> <subject>` and the exact `signup disallow` counterpart; never infer an
73
+ email or link accounts. Inspect `system events` and the target object after an ambiguous
47
74
  mutation failure before retrying.
48
75
 
49
76
  Use `void connect` for deployment onboarding: no arguments offers Cloudflare or a Void platform, `--platform cloudflare` signs in and selects an account, and a platform URL verifies discovery and signs in with a supported provider. It preserves existing project links when connecting elsewhere. `--no-login` saves only verified Void connection metadata; authenticated headless connection requires a valid origin-scoped keychain session or `VOID_TOKEN` with matching `VOID_API_URL`. Platform installation and administration use `void platform`.
50
77
 
78
+ For Void platform project access, use `void project team`: invite only an email already registered on that platform and assign `reader`, `collaborator`, or `admin`. The invited account uses `void connect <url>` followed by pending/accept/decline on that active connection, regardless of the current directory's project link or deploy target. `VOID_API_URL` overrides the connection; an unscoped `VOID_TOKEN` selects Void Cloud. Acceptance preserves directory links; run `void project link` in an unlinked checkout of the invited application. Project-scoped team management does not apply to direct Cloudflare deployments. Installation administrators transfer ownership with `void platform project owner <project-id> <user-id> --plan` and apply the reviewed transfer with `--yes`.
79
+
51
80
  Use Void commands for every user-facing workflow. Never ask the user to install, authenticate, or run Wrangler directly. Say Cloudflare or Void instead, except when naming literal `wrangler.jsonc` / `wrangler.json` files or `WRANGLER_*` environment variables the user must inspect.
52
81
 
53
82
  Use `void` in examples and commands in this skill. For first-time setup, prefer `void init` followed by `void deploy`; in an empty directory, install `void` first and let `void init` add the matching Pages adapter and starter dependencies with Vite+ as the default scaffold toolchain. In an existing app, `void init` configures Void in place by adding missing Vite scripts and creating or patching `vite.config.*` with `voidPlugin()`. For Cloudflare deployment, Void uses bundled tooling and secure browser OAuth; init or the first deploy saves the selected `account_id`, and `void cloudflare login|status|logout` manages the session; Void-managed deployment requires an explicitly connected platform. Use `void connect <url>` for an existing platform or `void platform install --plan` to preview a company control plane in the user's Cloudflare account. The core self-hosted platform excludes the dashboard, GitHub App, and build Containers; lifecycle commands are resumable and verify remote, Worker, and R2 ownership before mutations. `void platform disable` gates traffic through installation-owned routing storage and remains disabled through repair or upgrade; `void platform enable` explicitly restores traffic. Safe uninstall retains name-addressed Workers, R2, AI Gateway, external PostgreSQL, and zones for explicit manual cleanup. Direct Cloudflare deploys support native Void apps, static/SPA/SSG output, and Cloudflare builds from TanStack Start, React Router, vinext, SvelteKit, Nuxt, Analog, and Astro. They provision inferred resources, validate and apply migrations, require schema-declared server values in encrypted remote secret storage, upload and probe an immutable Worker Version, then activate and synchronize triggers. Native Void features are Workers Free-compatible by default; `void/sandbox` is an explicit exception because it uses Cloudflare Containers and therefore requires Workers Paid. A Sandbox deploy checks Containers access before provisioning/building, while apps without Sandbox perform no entitlement probe. Versions without preview URLs are staged at 0% and probed through workers.dev using a version override; the same safe fallback applies when Access blocks a generated preview alias but admits the stable Worker hostname. Set `CLOUDFLARE_WORKERS_SUBDOMAIN` in a fresh CI checkout, while local deploys cache it automatically. When Cloudflare Access protects `workers.dev`, use an admitted `CF_ACCESS_CLIENT_ID` / `CF_ACCESS_CLIENT_SECRET` service-token pair for CI, or a short-lived `CF_ACCESS_TOKEN` from `cloudflared` for an interactive local readiness probe. With Cloudflare saved in `.void/project.json`, secret, domain, project status/log/rollback, and remote database commands operate directly on the pinned Worker/account. Logs are a live tail. Rollback restores the selected version's saved schedules, queues, workflows, routes, and domains but never reverses database migrations; versions without complete trigger snapshots use code-only rollback and keep the current routes and schedules. On either deployment platform, auth-enabled `void deploy` preserves an existing `BETTER_AUTH_SECRET` or creates a persistent encrypted secret when missing; always use Void's deployment flow so it can manage that secret safely.
@@ -62,7 +91,7 @@ Cloudflare browser login does not grant AI Gateway access. A platform installati
62
91
 
63
92
  Use `--runtime <directory>` on platform install, upgrade, repair, enable, or rollback when deploying a locally built `@void/platform` runtime. The directory must contain the generated integrity manifest, Worker artifacts, and migration tree. Build it with `vp run --filter @void/platform build`; Git source revisions are detected automatically, with `-dirty` for uncommitted changes. `VOID_PLATFORM_SOURCE_REVISION` is an optional override. Do not treat this as a safety bypass: custom and packaged runtimes follow the same verification and rollback path.
64
93
 
65
- For self-hosted platform installation, require a dedicated empty PostgreSQL database. Interactive lifecycle commands use Cloudflare browser OAuth with keyring storage. Wrangler OAuth has Zone Read, so a plan that creates a zone or DNS record requires a scoped `CLOUDFLARE_API_TOKEN` with Account → Zone → Edit and/or Zone → DNS → Edit; finish Workers onboarding and choose a workers.dev subdomain for a fresh Cloudflare account. The installed platform uses a separate `VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN`. Its core permissions include Hyperdrive: Write at account scope and Cache Purge: Purge on the application zone; SSL and Certificates: Edit is needed only when a custom runtime enables custom project domains. The installer preflights account and Hyperdrive access and safely verifies Cache Purge for an existing zone with a unique nonexistent URL. Fresh installs require unique per-installation values from the operator's secret manager: `VOID_PLATFORM_JWT_SECRET` with at least 32 random bytes and `VOID_PLATFORM_PROJECT_SECRET_KEY` as canonical base64 for 32 random bytes. Never let a local checkpoint or ephemeral CI runner be their only custodian. The installer transactionally claims the database for one installation, pins that URL after a successful claim, never removes the claim during uninstall, and uses it for a cross-host lifecycle lock plus a secret-free authoritative lifecycle manifest. Local recovery checkpoints are AES-256-GCM encrypted with an OS-keychain key; never place platform secrets in plaintext files. After discovery on a new machine, provide `VOID_PLATFORM_DATABASE_URL`. Normal upgrades preserve deployed Worker secrets; restoring the original runtime, GitHub, R2, JWT, and project-encryption values is required only when recreating a missing Worker. Managed platform Sandbox is paused for removal: preview `void platform system sandbox-drain --plan`, follow each returned `nextCursor` with `--cursor '<value>'` to inspect bounded pages, then apply with `--yes` and rerun until the DB-backed response reports `complete: true`; never delete an unverified app by name. The installer trusts only DB-backed sandbox-drain probe protocol v1. Before it accepts an empty inventory, the database admission barrier makes older deployment inserts finish and become visible or rejects them after the protocol floor is armed. Lifecycle redeploys preserve unmanaged Worker routes and custom domains in both the new trigger state and rollback snapshot. They stop before migrations or uploads when live Hyperdrive origin metadata differs from the pinned database. Platform upgrade SQL is forward-only: packaged hashes and the live Drizzle prefix must match, pending migrations require an exact source-version/schema rollback edge, and the old version remains authoritative until target health succeeds. Use `void platform rollback --runtime <earlier>` only when the installed runtime declares the exact earlier version/schema compatible; pass `--from-runtime` for the exact current custom artifact. Rollback preserves the forward database schema and cannot lower a database-required safety protocol. Safe uninstall retains Worker routes, custom domains, and R2 for explicit manual cleanup because Cloudflare cannot condition their deletion on an immutable generation.
94
+ For self-hosted platform installation, require a dedicated empty PostgreSQL database. Interactive lifecycle commands use Cloudflare browser OAuth with keyring storage. Wrangler OAuth has Zone Read, so a plan that creates a zone or DNS record requires a scoped `CLOUDFLARE_API_TOKEN` with Account → Zone → Edit and/or Zone → DNS → Edit; finish Workers onboarding and choose a workers.dev subdomain for a fresh Cloudflare account. The installed platform uses a separate `VOID_PLATFORM_RUNTIME_CLOUDFLARE_API_TOKEN`. Its core permissions include Hyperdrive: Write at account scope and Cache Purge: Purge on the application zone; SSL and Certificates: Edit is needed only when a custom runtime enables custom project domains. Managed Sandbox apps additionally require Workers Paid plus Account → Containers → Edit and Account → Cloudchamber → Edit on that runtime token; these are checked on the first Sandbox application deploy, not during platform install or upgrade. The installer preflights account and Hyperdrive access and safely verifies Cache Purge for an existing zone with a unique nonexistent URL. Fresh installs require unique per-installation values from the operator's secret manager: `VOID_PLATFORM_JWT_SECRET` with at least 32 random bytes and `VOID_PLATFORM_PROJECT_SECRET_KEY` as canonical base64 for 32 random bytes. Never let a local checkpoint or ephemeral CI runner be their only custodian. Enabling email generates a separate signing key in encrypted recovery state; supply `VOID_PLATFORM_EMAIL_SIGNING_SECRET` from the secret manager for headless installs without persistent recovery files. The installer transactionally claims the database for one installation, pins that URL after a successful claim, never removes the claim during uninstall, and uses it for a cross-host lifecycle lock plus a secret-free authoritative lifecycle manifest. Local recovery checkpoints are AES-256-GCM encrypted with an OS-keychain key; never place platform secrets in plaintext files. After discovery on a new machine, provide `VOID_PLATFORM_DATABASE_URL`. For email-enabled installations without encrypted recovery state, also restore the original `VOID_PLATFORM_EMAIL_SIGNING_SECRET`; recreating only the email gateway needs this key, not JWT or runtime credentials. Normal upgrades preserve other deployed Worker secrets; restore the original runtime, GitHub, R2, JWT, and project-encryption values when recreating the API or proxy as documented. Upgrades enable managed Sandboxes automatically. An upgrade from the tenant-owned legacy may still require `void platform system sandbox-drain`: preview with `--plan`, follow each `nextCursor`, then apply with `--yes` until the DB-backed response reports `complete: true`; never delete an unverified app by name. The installer trusts only DB-backed sandbox-drain probe protocol v1. Before it accepts an empty inventory, the database admission barrier makes older deployment inserts finish and become visible or rejects them after the protocol floor is armed. Lifecycle redeploys preserve unmanaged Worker routes and custom domains in both the new trigger state and rollback snapshot. They stop before migrations or uploads when live Hyperdrive origin metadata differs from the pinned database. Platform upgrade SQL is forward-only: packaged hashes and the live Drizzle prefix must match, pending migrations require an exact source-version/schema rollback edge, and the old version remains authoritative until target health succeeds. Use `void platform rollback --runtime <earlier>` only when the installed runtime declares the exact earlier version/schema compatible; pass `--from-runtime` for the exact current custom artifact. Rollback preserves the forward database schema and cannot lower a database-required safety protocol. Safe uninstall retains Worker routes, custom domains, and R2 for explicit manual cleanup because Cloudflare cannot condition their deletion on an immutable generation.
66
95
 
67
96
  For self-hosted recovery, `VOID_PLATFORM_PROJECT_SECRET_KEY` restores an original `v1` project-secret keyring. After rotation, restore every retained version with `VOID_PLATFORM_PROJECT_SECRET_KEYS_JSON` and its active entry with `VOID_PLATFORM_PROJECT_SECRET_ACTIVE_KEY_VERSION`. These inputs restore a missing API Worker and never replace the live keyring of an existing Worker. Inject them from a secret manager without logging them or writing plaintext files.
68
97
 
@@ -131,3 +160,5 @@ Then ask what to do next.
131
160
  - If docs and memory differ, follow docs.
132
161
  - For Void-managed auth, `void db generate` automatically includes the production Better Auth schema in the generated migration, including configured model/field renames and plugin tables. Keep those migrations under `db/migrations/`; do not duplicate generated auth tables in the application Drizzle schema.
133
162
  - **Env vars:** When the project has `env.ts`, the canonical access pattern is `import { env } from "void/env"`. Declare every env key in `env.ts` via `defineEnv({...})` so values are typed and validated. Do not introduce ad-hoc `process.env.X` or untyped `c.env.X` access in new code — add the key to `env.ts` first.
163
+
164
+ For platform email domains, use `void email domain add` with an account- and zone-scoped Cloudflare API token. Read inbound, outbound, and management readiness separately; `domain sync` reconciles without rotating secrets. Use a stable `sendEmail({ idempotencyKey })` for retryable sends and inspect `void email logs` when the result is `OUTCOME_UNKNOWN`; never retry an uncertain send with a new key. Administrators can inspect retained outcomes with `void platform email logs <project-id|slug> --json`; use the project ID after deletion. Native Cloudflare email setup remains `void email setup --platform cloudflare` and does not support platform idempotency keys.
@@ -14,23 +14,23 @@ Direct Cloudflare deployment runs an application in your account. A self-hosted
14
14
  Void platform provides that deployment service to a team using the operator's
15
15
  account. Both use the same application APIs, with the following differences:
16
16
 
17
- | Feature | Direct Cloudflare | Core self-hosted platform |
18
- | ------------------------------------------------ | -------------------------------- | ----------------------------------------- |
19
- | Static sites, SPAs, and native Pages SSR | Supported | Supported |
20
- | Routing rules, WebSockets, queues, cron, and ISR | Supported | Supported |
21
- | D1, KV, R2, and external SQL through Hyperdrive | Supported | Supported |
22
- | Workers AI and provider requests | Your account and gateway | Installation gateway and proxy |
23
- | Runtime logs | Live Cloudflare tail | Retained platform logs |
24
- | Application rollback | Worker versions | Retained platform deployments |
25
- | Typed Durable State | Supported | Unavailable |
26
- | Sandboxes | Requires Workers Paid and Docker | Unavailable in beta |
27
- | Custom application domains | Supported | Unavailable in the core installation |
28
- | Generated GitHub deployment workflow | Supported | Use your CI with a scoped developer token |
29
- | User dashboard and managed GitHub builds | Not required | Not included in a standard installation |
17
+ | Feature | Direct Cloudflare | Core self-hosted platform |
18
+ | ------------------------------------------------ | -------------------------------- | --------------------------------------------- |
19
+ | Static sites, SPAs, and native Pages SSR | Supported | Supported |
20
+ | Routing rules, WebSockets, queues, cron, and ISR | Supported | Supported |
21
+ | D1, KV, R2, and external SQL through Hyperdrive | Supported | Supported |
22
+ | Workers AI and provider requests | Your account and gateway | Installation gateway and proxy |
23
+ | Runtime logs | Live Cloudflare tail | Retained platform logs |
24
+ | Application rollback | Worker versions | Retained platform deployments |
25
+ | Typed Durable State | Supported | Unavailable |
26
+ | Sandboxes | Requires Workers Paid and Docker | Requires Workers Paid on the platform account |
27
+ | Custom application domains | Supported | Unavailable in the core installation |
28
+ | Generated GitHub deployment workflow | Supported | Use your CI with a scoped developer token |
29
+ | User dashboard and managed GitHub builds | Not required | Not included in a standard installation |
30
30
 
31
31
  Ordinary native applications remain compatible with Workers Free within its
32
32
  quotas. Installing a team platform requires Workers for Platforms and the
33
- [documented infrastructure](./self-hosted-platform.md#cloudflare-footprint).
33
+ [documented infrastructure](./platform/installation/domains.md#cloudflare-footprint).
34
34
  Application rollback never reverses database migrations.
35
35
 
36
36
  Routing-rule parity applies to native Void applications and static deployments.
@@ -60,6 +60,8 @@ Already have a Cloudflare Worker and a root `wrangler.jsonc` or `wrangler.json`?
60
60
 
61
61
  To connect to your team's platform and sign in, run `void connect <url>` using the URL from your administrator. Use `void connect --platform cloudflare` to set up your own Cloudflare account. Connecting preserves existing project links. New Void projects need a platform connection.
62
62
 
63
+ Owners can [share a platform project](./project-collaboration.md) with readers, collaborators, and project administrators. This does not apply to direct Cloudflare deployments.
64
+
63
65
  ### Migrations
64
66
 
65
67
  If your app uses Drizzle, `void deploy` runs migrations as part of the deploy flow:
@@ -10,7 +10,7 @@ Send transactional email from your app via [Cloudflare's `send_email` binding](h
10
10
  import { sendEmail } from 'void/email';
11
11
 
12
12
  const result = await sendEmail({
13
- from: 'Acme <acme+noreply@mail.void.cloud>',
13
+ from: 'Acme <acme+noreply@mail.example.com>', // use your project's sender address
14
14
  to: 'user@example.com',
15
15
  subject: 'Welcome',
16
16
  text: 'Thanks for signing up!',
@@ -19,7 +19,7 @@ const result = await sendEmail({
19
19
 
20
20
  if (!result.ok) {
21
21
  if ('error' in result) {
22
- // Nothing was sent — the whole call failed before delivery.
22
+ // A request-level failure; OUTCOME_UNKNOWN may already have been submitted.
23
23
  console.error(result.error.code, result.error.message);
24
24
  } else {
25
25
  // Some recipients failed. `deliveries` says which.
@@ -36,9 +36,13 @@ hits first, because a recipient you have not verified yet fails per-recipient.
36
36
 
37
37
  ## Setup
38
38
 
39
- Zero config on the Void platform (`void deploy`). Every Void project ships with:
39
+ On a platform with email enabled, your app needs no email configuration before
40
+ `void deploy`. Ask your administrator for the platform's shared mail domain. A
41
+ self-hosted administrator [enables email during installation or upgrade](/guide/platform/installation/credentials#runtime-token-permissions); installations without it do not offer platform email. Void Cloud uses `mail.void.cloud`.
40
42
 
41
- - **Sender** — `<your-slug>+noreply@mail.void.cloud`. Used as the default `from` if you omit it. The platform owns the zone with Email Routing + DKIM + SPF + DMARC set up; you do nothing. Project slugs are capped at 56 characters so this local part fits RFC 5321's 64 octets; a project created before the cap with a longer slug must pass `from` explicitly.
43
+ Each project on an email-enabled platform has:
44
+
45
+ - **Sender** — `<your-slug>+noreply@<mail-domain>`. Used as the default `from` if you omit it. The platform administrator configures the mail zone and its Email Routing, DKIM, SPF, and DMARC records. Project slugs are capped at 56 characters so this local part fits RFC 5321's 64 octets; a project created before the cap with a longer slug must pass `from` explicitly.
42
46
  - **No worker binding to add** — outbound mail is sent by the Void proxy, which holds the
43
47
  platform `send_email` binding. Your worker never gets one, so there is nothing to configure.
44
48
  - **Your own address as a recipient** — the email on your Void account is registered as a recipient when the project is created. It is verified at once when Cloudflare already holds it verified for the platform (you clicked its link for an earlier project of yours); otherwise Cloudflare mails it a verification link, and until you click that link and run `void email destinations` — the listing is what records the click — a send to yourself comes back `ok: false` with a per-recipient `UNVERIFIED_DESTINATION` in `result.deliveries`.
@@ -61,7 +65,7 @@ Cloudflare emails the recipient with a verification link. Once they click it and
61
65
  void email destinations
62
66
  ```
63
67
 
64
- The project owner's email (the GitHub address you signed up with) is added automatically when the project is created, so it skips `void email allow` — not the verification. See [Setup](#setup) for when it is verified at once and when there is a link to click.
68
+ The project owner's account email is added automatically when the project is created on an email-enabled platform, so it skips `void email allow` — not the verification. See [Setup](#setup) for when it is verified at once and when there is a link to click.
65
69
 
66
70
  ::: warning When this is the right fit
67
71
  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.
@@ -71,54 +75,56 @@ For SaaS sending to arbitrary end-users (every signup gets a welcome email), the
71
75
 
72
76
  ## Your own domain on the platform
73
77
 
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:
78
+ The shared sender uses the platform's configured mail domain. To send — and receive — at a domain you own, register its Cloudflare zone with the project:
75
79
 
76
80
  ```sh
77
81
  void email domain add acme.com
78
82
  ```
79
83
 
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.
84
+ `add` opens a Cloudflare API-token template. Restrict the token to the selected account and mail zone before creating it; Workers Scripts permission applies across that account. The CLI accepts a masked paste or a newly copied token and asks you to confirm those restrictions. The platform encrypts the credential for the zone connection, so projects sharing that zone do not need separate ingress Workers.
81
85
 
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.
86
+ When an apex already receives mail, the CLI proposes `mail.<domain>`. Use `--subdomain <label|host>` to choose another mail subdomain. Void refuses to replace a foreign enabled catch-all. A domain belongs to one project; multiple exact domains can share a zone, and a project can register more than one domain.
83
87
 
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:
88
+ Setup returns an operation ID. If your connection drops, the platform keeps the recorded operation and the CLI checks that operation's status. An uncertain Cloudflare write stops conflicting changes until its outcome can be established.
85
89
 
86
- ```sh
87
- void email domain status acme.com
88
- ```
90
+ `void email domain status <domain>` reports three independent results:
89
91
 
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.
92
+ - **Inbound**: whether mail can reach the project's handlers.
93
+ - **Outbound**: whether sending is ready, restricted to verified destinations, pending, or blocked.
94
+ - **Management**: whether the stored Cloudflare credential can manage the connection.
91
95
 
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.
96
+ Use `void email domain sync <domain>` to reconcile setup and refresh readiness. Sync does not rotate the connection secret. Use `void email domain rotate-secret <domain>` for an explicit rotation; it applies to all domains sharing that zone connection and verifies the deployed secret before completing. Rotation requires inbound readiness; run `sync` first if setup is incomplete. If a dashboard or credential step is required, the status names it. Removing a domain disables its assignment; zone resources used by another domain remain in place, and unfinished cleanup stays recorded for reconciliation.
93
97
 
94
- Two upkeep verbs:
98
+ After the last assignment's ingress is deleted, the platform forgets its stored
99
+ credential. Revoke a token you no longer use in Cloudflare; Void does not revoke
100
+ tokens you created yourself. Re-adding a removed domain requires a scoped token
101
+ again. A domain can move to another project only after its removal completes
102
+ and the zone connection's manager authorizes the new assignment.
95
103
 
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.
104
+ Once inbound is ready, mail to any address on that domain reaches your `email/` handlers. Sending to arbitrary recipients also needs outbound readiness; a domain limited to verified destinations still requires recipient verification. Platform quotas and suspension apply to both shared and custom senders.
98
105
 
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.
106
+ On an administrator-managed platform, `add` prints the administrator command for new domains. Existing owner-managed connections remain available to their owner. Your administrator may permit shared-sender mail to specific recipient domains or any recipient; destinations on the platform's shared mail domain still require explicit verification.
102
107
 
103
108
  ## Options
104
109
 
105
- | Option | Type | Notes |
106
- | ------------- | ---------------------------- | ---------------------------------------------------------------- |
107
- | `from` | `string \| { email, name? }` | Optional. Pinned to the project sender — see below. |
108
- | `to` | `Address \| Address[]` | Required. One or more recipients. |
109
- | `subject` | `string` | Required. UTF-8 supported (encoded as RFC 2047). |
110
- | `text` | `string` | At least one of `text` / `html` is required. |
111
- | `html` | `string` | Sent as `multipart/alternative` if both are provided. |
112
- | `replyTo` | `Address` | Optional `Reply-To` header. |
113
- | `cc`, `bcc` | `Address \| Address[]` | Optional. Each recipient is sent its own message envelope. |
114
- | `headers` | `Record<string, string>` | Custom headers; reserved headers (From, Date, etc.) are ignored. |
115
- | `attachments` | `Attachment[]` | See [Attachments](#attachments). |
116
-
117
- At most 100 recipients across `to`, `cc` and `bcc` per call. Each address is checked with [`email-validator`](https://www.npmjs.com/package/email-validator): an ASCII dot-atom local part (before the `@`) of at most 64 characters — no whitespace, control character or RFC 5322 special, no leading, trailing or doubled dot, and no quoted local part — and a dotted domain of ASCII labels (at most 63 characters each) whose TLD starts with a letter and is at least 2 characters, so `user@localhost` is refused and an IDN domain must be given as punycode (`xn--…`); at most 254 characters in total (RFC 5321: a 256-octet forward-path `<local@domain>` and a 64-octet local part are the longest every receiver must accept). The address itself carries no display-name syntax; a display name goes around it (`"Name <addr>"` or `{ email, name }`). All of these are checked before anything is built and return `INVALID_TO` (`INVALID_FROM` for `from`; `MIME_ERROR` for `cc`, `bcc` and `replyTo`). The platform's inbound router applies the same package to `forward()` and `reply()` addresses, so nothing a handler records is dropped there for its shape. Custom header names must be RFC 5322 field names (printable ASCII, no colon); a name too long to fit a 998-octet line — a field name cannot be folded — is rejected with `MIME_ERROR` before anything is built.
110
+ | Option | Type | Notes |
111
+ | ---------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------- |
112
+ | `from` | `string \| { email, name? }` | Optional. Pinned to the project sender — see below. |
113
+ | `to` | `Address \| Address[]` | Required. One or more recipients. |
114
+ | `subject` | `string` | Required. UTF-8 supported (encoded as RFC 2047). |
115
+ | `text` | `string` | At least one of `text` / `html` is required. |
116
+ | `html` | `string` | Sent as `multipart/alternative` if both are provided. |
117
+ | `replyTo` | `Address` | Optional `Reply-To` header. |
118
+ | `cc`, `bcc` | `Address \| Address[]` | Optional. Each recipient is sent its own message envelope. |
119
+ | `headers` | `Record<string, string>` | Custom headers; reserved headers (From, Date, etc.) are ignored. |
120
+ | `attachments` | `Attachment[]` | See [Attachments](#attachments). |
121
+ | `idempotencyKey` | `string` | Optional on the Void Platform: 1–128 printable, non-space ASCII characters. Reuse for retries of the same send. |
122
+
123
+ At most 50 recipients across `to`, `cc` and `bcc` per call. Each address is checked with [`email-validator`](https://www.npmjs.com/package/email-validator): an ASCII dot-atom local part (before the `@`) of at most 64 characters — no whitespace, control character or RFC 5322 special, no leading, trailing or doubled dot, and no quoted local part — and a dotted domain of ASCII labels (at most 63 characters each) whose TLD starts with a letter and is at least 2 characters, so `user@localhost` is refused and an IDN domain must be given as punycode (`xn--…`); at most 254 characters in total (RFC 5321: a 256-octet forward-path `<local@domain>` and a 64-octet local part are the longest every receiver must accept). The address itself carries no display-name syntax; a display name goes around it (`"Name <addr>"` or `{ email, name }`). All of these are checked before anything is built and return `INVALID_TO` (`INVALID_FROM` for `from`; `MIME_ERROR` for `cc`, `bcc` and `replyTo`). The platform's inbound router applies the same package to `forward()` and `reply()` addresses, so nothing a handler records is dropped there for its shape. Custom header names must be RFC 5322 field names (printable ASCII, no colon); a name too long to fit a 998-octet line — a field name cannot be folded — is rejected with `MIME_ERROR` before anything is built.
118
124
 
119
125
  `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.
120
126
 
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.
127
+ On the platform the sender is pinned to your project. `from` must be your project's own platform address — `<project-slug>@<mail-domain>` or `<project-slug>+<tag>@<mail-domain>`, optionally with a display name — 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)). For example, if your platform's mail domain is `mail.example.com`, you can use `Acme <acme+noreply@mail.example.com>`. Anything else is rejected with `INVALID_FROM`. Omit `from` and Void fills in `<project-slug>+noreply@<mail-domain>` for you.
122
128
 
123
129
  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`.
124
130
 
@@ -126,7 +132,6 @@ On your own Cloudflare account, `from` defaults to `email.from` from `void.json`
126
132
 
127
133
  ```ts
128
134
  await sendEmail({
129
- from: 'Acme <acme+noreply@mail.void.cloud>',
130
135
  to: 'user@example.com',
131
136
  subject: 'Your receipt',
132
137
  text: 'Receipt attached.',
@@ -144,7 +149,6 @@ For inline images (e.g. logos referenced from HTML), set `disposition: 'inline'`
144
149
 
145
150
  ```ts
146
151
  await sendEmail({
147
- from: 'Acme <acme+noreply@mail.void.cloud>',
148
152
  to: 'user@example.com',
149
153
  subject: 'Hello',
150
154
  html: '<img src="cid:logo" alt="Acme">',
@@ -160,66 +164,52 @@ await sendEmail({
160
164
  });
161
165
  ```
162
166
 
163
- `contentType` is inferred from the filename extension when omitted; when given, it must be a valid media type (`type/subtype`, optionally followed by `; attribute=value` parameters — no `name`, which is set from `filename`), or `sendEmail` returns `MIME_ERROR`. `contentId` is the identifier the HTML references as `cid:<id>`: letters, digits, the RFC 5322 `atext` symbols and dots, optionally with an `@domain` part and optionally in one pair of angle brackets (`logo`, `logo@acme.dev` and `<logo@acme.dev>` all render as `Content-ID: <…>`); anything else — whitespace, quotes, parentheses, a stray `<` or `>`, or an empty string — returns `MIME_ERROR`. Total message size (after base64 expansion) is capped at 10 MB to match Cloudflare's limit; oversize payloads return `MIME_ERROR` instead of failing upstream.
167
+ `contentType` is inferred from the filename extension when omitted; when given, it must be a valid media type (`type/subtype`, optionally followed by `; attribute=value` parameters — no `name`, which is set from `filename`), or `sendEmail` returns `MIME_ERROR`. `contentId` is the identifier the HTML references as `cid:<id>`: letters, digits, the RFC 5322 `atext` symbols and dots, optionally with an `@domain` part and optionally in one pair of angle brackets (`logo`, `logo@acme.dev` and `<logo@acme.dev>` all render as `Content-ID: <…>`); anything else — whitespace, quotes, parentheses, a stray `<` or `>`, or an empty string — returns `MIME_ERROR`. Total encoded message size is capped at 5 MiB, and custom headers at 16 KiB; oversize payloads return `MIME_ERROR` instead of failing upstream.
164
168
 
165
169
  ## Result and errors
166
170
 
167
- `sendEmail` returns a discriminated union — there are no thrown errors:
171
+ `sendEmail` returns a result instead of throwing. A successful result means the provider accepted the send; it does not confirm delivery to the recipient's mailbox.
168
172
 
169
- ```ts
170
- interface SendEmailDelivery {
171
- recipient: string; // the envelope `To` used for this CF send
172
- messageId: string;
173
- }
174
-
175
- type SendEmailRecipientResult =
176
- | { recipient: string; ok: true; messageId: string }
177
- | { recipient: string; ok: false; error: SendEmailError };
178
-
179
- type SendEmailResult =
180
- | { ok: true; ids: SendEmailDelivery[] }
181
- | { ok: false; error: SendEmailError } // pre-flight failure
182
- | { ok: false; deliveries: SendEmailRecipientResult[] }; // mid-batch failure
183
- ```
173
+ `ok: true` carries `ids`, one per unique recipient. Addresses are compared case-insensitively across `to`, `cc`, and `bcc`. A custom-domain provider batch can report the same provider reference for several recipients.
184
174
 
185
- `ids` carries one entry per envelope send. Because the CF `EmailMessage` envelope is single-recipient, multi-recipient calls (`to`/`cc`/`bcc`) fan out into one CF send per unique address, each with its own messageId. Addresses are matched case-insensitively across the three fields and `recipient` is the lowercased address; a capture under `void dev` or the test harness reports the same list.
175
+ `ok: false` carries either a top-level `error` or a complete per-recipient `deliveries` list. Platform results include an `operationId` when available, and per-recipient `state` distinguishes rejection, reservation, submission, provider acceptance, failure, cancellation, and an unknown outcome.
186
176
 
187
- There are three discriminated cases:
177
+ Provider submissions have a 30-second wait limit. Platform `sendEmail` requests are bounded to 60 seconds, including admission and recording the result; native and inbound handler submissions share a 60-second batch budget. A submission that times out returns `OUTCOME_UNKNOWN`; the provider may still accept it later.
188
178
 
189
- - **All success** (`ok: true`) — every recipient delivered.
190
- - **Pre-flight failure** (`ok: false`, has `error`) — validation / MIME / missing binding rejected the call before any sends were attempted. Retrying the whole call is safe.
191
- - **Mid-batch failure** (`ok: false`, has `deliveries`) — sends were attempted and some failed. Each recipient has its own per-recipient outcome (`ok: true` with `messageId`, or `ok: false` with `error`). Retry only recipients with `ok: false` — re-sending to ones with `ok: true` will deliver duplicates. The list always names every recipient of the call; an incomplete or malformed list from the platform proxy is reported as a top-level `UPSTREAM_ERROR` (`result.error`), never as a partial `deliveries`.
179
+ Use an idempotency key for sends you may retry:
192
180
 
193
181
  ```ts
194
- const result = await sendEmail({ to: ['a@x.dev', 'b@x.dev', 'c@x.dev'], ... });
182
+ const result = await sendEmail({
183
+ to: 'user@example.com',
184
+ subject: 'Invoice ready',
185
+ text: 'Your invoice is available in your account.',
186
+ idempotencyKey: 'invoice:42:ready',
187
+ });
188
+
195
189
  if (result.ok) {
196
- // every recipient delivered
197
- } else if ('error' in result) {
198
- // pre-flight failure — retry the whole call
190
+ console.log('Accepted by the provider', result.operationId);
199
191
  } else {
200
- // mid-batch failure — retry only the failed recipients
201
- const toRetry = result.deliveries.filter((d) => !d.ok).map((d) => d.recipient);
192
+ console.log('Inspect the outcome before retrying', result.operationId, result);
202
193
  }
203
194
  ```
204
195
 
205
- Error codes:
206
-
207
- | Code | Meaning |
208
- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
209
- | `BINDING_MISSING` | No email transport: neither the worker's own `SEND_EMAIL` binding (an own-account deploy without `email.from`) nor the platform proxy is available, or a Class B/C dev server. |
210
- | `INVALID_FROM` | `from` was missing, malformed, not the project sender (platform), or a sender Cloudflare would not accept (own account). |
211
- | `INVALID_TO` | `to` was empty, contained an invalid or over-long address, or the call had more than 100 recipients. |
212
- | `UNVERIFIED_DESTINATION` | Cloudflare rejected the recipient as unverified. |
213
- | `MIME_ERROR` | Failed to build the MIME message (oversize attachments, malformed input). |
214
- | `QUOTA_EXCEEDED` | Platform quota for outbound email reached (retry next billing period), or Cloudflare's own rate or daily limit on the sending account — the platform's, or your own. |
215
- | `UPSTREAM_ERROR` | Other binding failure; the original error is attached as `error.cause`. |
216
-
217
- Which branch carries the code depends on when the send failed.
218
- `BINDING_MISSING`, `INVALID_FROM`, `INVALID_TO` and `MIME_ERROR` are pre-flight,
219
- so they arrive as a top-level `result.error`. `UNVERIFIED_DESTINATION` is always
220
- per-recipient and therefore only ever appears inside `result.deliveries` —
221
- never as `result.error`. `QUOTA_EXCEEDED` and `UPSTREAM_ERROR` can arrive either
222
- way. Narrow with `'error' in result` rather than assuming.
196
+ For 30 days, repeating a key with the same payload returns its recorded outcome without another provider submission. Reusing it with a different payload returns `IDEMPOTENCY_CONFLICT`. A lost response or interrupted provider request can return `OUTCOME_UNKNOWN`; check `void email logs` or repeat the same key. A new key creates a new send and can produce a duplicate. Native Cloudflare binding sends do not support platform idempotency keys.
197
+
198
+ | Code | Meaning |
199
+ | ------------------------ | -------------------------------------------------------------- |
200
+ | `BINDING_MISSING` | No email transport is configured. |
201
+ | `INVALID_FROM` | The sender is invalid or not authorized for the project. |
202
+ | `INVALID_TO` | The recipient list is invalid or exceeds 50 recipients. |
203
+ | `UNVERIFIED_DESTINATION` | The recipient needs verification under the active policy. |
204
+ | `MIME_ERROR` | The message is invalid or exceeds a size limit. |
205
+ | `QUOTA_EXCEEDED` | A platform or provider quota has been reached. |
206
+ | `IDEMPOTENCY_CONFLICT` | The key was already used for another payload. |
207
+ | `OUTCOME_UNKNOWN` | The send may have reached the provider; do not blindly resend. |
208
+ | `UPSTREAM_ERROR` | The provider or platform refused the request. |
209
+
210
+ The default platform allowance is 200 recipient submissions per UTC calendar month and 10 in a rolling 60-second window. Reserved submissions count toward the limits. Hourly cleanup cancels reservations older than 15 minutes that never started and releases their quota. Once an attempt starts, it stays charged even if the provider fails or the outcome is unknown. Administrators can change these limits.
211
+
212
+ `void email usage` shows monthly recipient attempts, inbound receipts, and the remaining allowance. `void email logs --limit 50` shows up to 100 recent metadata entries retained for 30 days: operation, recipient, direction, state, provider reference, and error code. It does not store subjects, bodies, or attachments. Receiving a message and sending a reply are separate events.
223
213
 
224
214
  ## Local development
225
215
 
@@ -253,7 +243,7 @@ The dev inbox is **not** available under a Class B or C framework — SvelteKit,
253
243
 
254
244
  ```ts
255
245
  const result = await sendEmail({
256
- from: 'acme+noreply@mail.void.cloud',
246
+ from: 'acme+noreply@mail.example.com', // replace with your project sender
257
247
  to: 'verified@acme.dev',
258
248
  subject: 'Skips the dev inbox',
259
249
  text: 'Not captured — and not delivered either.',
@@ -276,7 +266,7 @@ describe('signup flow', () => {
276
266
  const inbox = createEmailTestHarness();
277
267
 
278
268
  await sendEmail({
279
- from: 'acme+noreply@mail.void.cloud',
269
+ from: 'acme+noreply@mail.example.com', // use your project sender
280
270
  to: 'user@example.com',
281
271
  subject: 'Welcome',
282
272
  text: 'Hi!',
@@ -374,47 +364,49 @@ export default defineEmail(async (message, env, ctx, info) => {
374
364
 
375
365
  Everything taken from the inbound message is best-effort, because the remote sender controls it. A `Message-ID` or `References` value too long for a header line is dropped rather than threaded, and a `References` chain over 8 KiB keeps only its newest ids — the parent's `Message-ID` always ends it. Control characters in the subject become a space, and a subject over 4096 characters, or an ASCII one too long to fold, falls back to a bare `Re:`. None of these fallbacks suppresses the reply; pass `subject` to control it exactly.
376
366
 
377
- On the platform the `from` default is **not** `message.to`. A reply is sent from
378
- your project's own slug on the platform mail zone — `<slug>+noreply@<domain>` —
379
- built from the `__VOID_PROJECT_SLUG` and `__VOID_EMAIL_DOMAIN` bindings the
380
- platform injects. `replyEmail` throws when the slug is set but the domain is
381
- absent and you omitted `from`, rather than guessing a sender. With no slug at
382
- all — a worker on your own Cloudflare account — the reply is sent from
383
- `message.to`, the address on your own zone the message was delivered to.
367
+ On the platform, shared-domain replies default to
368
+ `<slug>+noreply@<mail domain>`. For mail received at a registered custom domain,
369
+ replies default to the address that received the message. `replyEmail` throws
370
+ when it cannot determine a sender. Native Cloudflare replies default to `message.to`.
384
371
 
385
372
  ::: warning The platform pins the reply sender
386
- You may override `from`, but only with an address on your own slug. The proxy
387
- accepts a reply sender whose local-part before the first `+` equals the slug the
388
- message was delivered to, on the zone it arrived on. Anything else is **dropped
389
- silently** — `replyEmail` still resolves, and the only trace is in the platform's
390
- log stream, not your project's.
391
-
392
- This is what stops one project replying as another project's address. Note that
393
- `message.to` is the _stripped_ address (`<tag>@<domain>`), so `from: message.to`
394
- is exactly the shape the pin rejects.
373
+ You may override `from` with your project's shared address or an address on
374
+ a registered custom domain your project owns. An unauthorized sender is rejected
375
+ and recorded in `void email logs`.
376
+
377
+ For shared addressing, `message.to` has the project prefix removed. Use the
378
+ default sender instead of copying that stripped address into `from`.
395
379
  :::
396
380
 
397
381
  ::: warning The platform gates the reply recipient
398
- A reply's `to` goes through the same gate as `sendEmail()` and `forward()`: it
399
- must be one of your project's verified destinations —
400
- `void email allow <address>`, then the recipient's verification click. A reply
401
- to any other address is **dropped silently**: the inbound message is still
402
- accepted, `replyEmail` still resolves, nothing reaches your worker, and the
403
- only trace is in the platform's log stream, not your project's.
404
-
405
- So the ticket example above answers only senders you have allowlisted. An
406
- auto-responder to arbitrary senders needs
407
- [your own Cloudflare account](#your-own-cloudflare-account)
408
- (`--platform cloudflare`), where `reply()` is Cloudflare's own reply-to-sender
409
- and Void adds no allowlist.
382
+ A reply's `to` goes through the platform's recipient policy and the transport's
383
+ capability checks. Shared sending defaults to your project's verified
384
+ destinations: run `void email allow <address>` and have the recipient complete
385
+ verification. A blocked reply is recorded in `void email logs`; the inbound
386
+ message remains accepted.
387
+
388
+ Custom-domain sends may reach external recipients when Cloudflare Sending is
389
+ enabled. Addresses on the platform's own mail domain always require per-project
390
+ consent. Native forwarding and reply operations can have additional Cloudflare
391
+ restrictions; check the recorded outcome before assuming a reply was accepted.
410
392
  :::
411
393
 
412
- Note the asymmetry with `sendEmail`: `sendEmail` returns an error result and
413
- never throws, while `replyEmail` throws when it cannot determine a sender.
394
+ In a platform handler, awaiting `replyEmail` or `message.forward` records an
395
+ action for execution after the handler finishes. Use `void email logs` to see
396
+ its provider outcome. `sendEmail` returns its send result directly.
397
+
398
+ `message.forward` requires a native Cloudflare email event. A message relayed
399
+ from a customer zone cannot use native forwarding; its forward action is
400
+ recorded as `UNSUPPORTED_ACTION` without sending or consuming quota. Replies
401
+ remain available through that domain's sending capability.
414
402
 
415
403
  ### Configuring inbound delivery
416
404
 
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.
405
+ On an email-enabled platform, `<slug>+anything@<mail domain>` reaches your
406
+ deployed `email/` handlers without per-project DNS setup. A
407
+ [registered custom domain](#your-own-domain-on-the-platform) reaches those
408
+ handlers once its inbound readiness is `ready`. Use `void email domain status`
409
+ to check current provider routing and any remaining setup steps.
418
410
 
419
411
  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).
420
412
 
@@ -478,7 +470,7 @@ describe('email handlers', () => {
478
470
  });
479
471
  ```
480
472
 
481
- The harness uses the same precedence rules as the production dispatcher. Reserved key `_default` mirrors `email/_default.ts`. It also applies the platform's inbound admission limits before your handler runs: a message over 10 MiB, or carrying more than 1024 distinct headers, is recorded in `rejects` and the handler is not called — exactly what the platform does before dispatch. With `slug: null` there is no platform router in front of the worker, so neither limit applies.
473
+ The harness uses the same precedence rules as the production dispatcher. Reserved key `_default` mirrors `email/_default.ts`. It also applies the platform's inbound admission limits before your handler runs: a message over 10 MiB, or carrying more than 1024 headers or 128 KiB of header data, is recorded in `rejects` and the handler is not called — exactly what the platform does before dispatch. With `slug: null` there is no platform router in front of the worker, so neither limit applies.
482
474
 
483
475
  ## Your own Cloudflare account
484
476
 
@@ -534,7 +526,9 @@ Enter (Yes is the default) applies only the `+` rows that are not ready, then th
534
526
  outbound sendEmail() from support@mail.acme.com
535
527
  ```
536
528
 
537
- On the second deploy every row reads ready: no prompt, no account call, and wrangler reports `Email Routing rules are up to date.` Answer No, and the deploy continues without email.
529
+ On the second deploy every row reads ready: no prompt, no account call, and wrangler reports `Email Routing rules are up to date.` Answer No before any setup has been committed, and the deploy continues without email.
530
+
531
+ DNS can take a few minutes to become visible to the resolver running the deploy. If `void email setup` has already written the exact `addresses` plan but that resolver still sees no subdomain MX, Void preserves the plan and stops before the build or upload. Run `void email status --platform cloudflare`, then retry the deploy after it sees Cloudflare's MX records.
538
532
 
539
533
  Two rows live in `wrangler.jsonc` rather than in your account, and a deploy that finds every account row ready reconciles them with a plain file write, no prompt: the `addresses` array is rewritten whenever it is not the current derivation (an entry pruned since, a worker rename, a new handler), and `vars.__VOID_EMAIL_FROM` follows a changed `email.from`. Routing rules are `wrangler deploy`'s own work, so a committed `addresses` entry whose rule does not exist yet — the state right after `void email setup` — still reads ready; the address map marks it `(rule created by this deploy)`.
540
534
 
@@ -581,7 +575,7 @@ How handlers become addresses, with `email.from` on `mail.acme.com` under the zo
581
575
  | `email/_default.ts` | **none** — a catch-all exists only on an apex | `*@acme.com` (the catch-all) |
582
576
  | `email/[user].ts`, `email/[user]+[tag].ts` (dynamic local part) | **refused** — a dynamic local part needs a catch-all | `*@acme.com` (the catch-all) |
583
577
 
584
- On a subdomain, `email/_default.ts` gets no rule and never runs: mail to any other `@mail.acme.com` address bounces at Cloudflare. The checklist says so in a `!` row, and the handler is absent from the address map.
578
+ On a subdomain, `email/_default.ts` gets no rule of its own, so it cannot make arbitrary `@mail.acme.com` addresses reach the worker. Mail admitted by an explicit rule can still fall through to `_default` when no handler pattern matches—for example, bare `support@mail.acme.com` admitted by the rule for `support+anything@`. Addresses that match no Cloudflare rule bounce before the worker runs. The checklist says so in a `!` row, and the handler is absent from the address map.
585
579
 
586
580
  Inside the worker, a message reaches your handlers exactly as addressed — `support+T-42@mail.acme.com` matches `email/support+[ticket].ts` with `info.params.ticket === "T-42"` — and `setReject`, `forward` and `replyEmail` act on the real message. `replyEmail` defaults `from` to `message.to`, the address on your zone the mail was delivered to.
587
581
 
@@ -612,9 +606,9 @@ deploy: email on mail.acme.com is not set up, and this shell cannot ask.
612
606
  Run `void email setup --platform cloudflare` once locally, commit wrangler.jsonc, then redeploy — deploying without email.
613
607
  ```
614
608
 
615
- then deploys **without** email. Pass `--require-email` to fail instead. Automatic resource provisioning is not an escape hatch: it creates D1/KV/R2/Queue/Hyperdrive resources, never a mail setup. To read the rows without deploying or being asked anything, run `void email status --platform cloudflare`.
609
+ then deploys **without** email. Pass `--require-email` to fail instead. When setup has already committed the exact subdomain `addresses` plan and only its MX records are not visible yet, every deploy stops and preserves that plan until DNS can be verified. Automatic resource provisioning is not an escape hatch: it creates D1/KV/R2/Queue/Hyperdrive resources, never a mail setup. To read the rows without deploying or being asked anything, run `void email status --platform cloudflare`.
616
610
 
617
- So the CI story is: run `void email setup --platform cloudflare` once on your machine (it runs the same preflight, checklist and prompt as the first deploy, then writes `wrangler.jsonc`, without deploying), commit `wrangler.jsonc`, and let CI run `void deploy --platform cloudflare --require-email`. With the binding and `addresses` committed, every row reads ready and nothing is asked — on Workers Free too, where the committed binding is what remembers the refused sending onboarding (see [Sending](#sending)).
611
+ So the CI story is: run `void email setup --platform cloudflare` once on your machine (it runs the same preflight, checklist and prompt as the first deploy, then writes `wrangler.jsonc`, without deploying), commit `wrangler.jsonc`, and let CI run `void deploy --platform cloudflare --require-email`. Once the MX records are visible, the committed binding and `addresses` make every row read ready and nothing is asked — on Workers Free too, where the committed binding is what remembers the refused sending onboarding (see [Sending](#sending)).
618
612
 
619
613
  ### If the subdomain step is refused
620
614