void 0.20.0 → 0.20.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -1
- package/dist/{auth-W9WII-mN.mjs → auth-DPl6kck4.mjs} +46 -26
- package/dist/{auth-cmd-CYVhSNFy.mjs → auth-cmd-gniL2fNt.mjs} +5 -4
- package/dist/auth-link-NZdjCmSc.mjs +28 -0
- package/dist/{build-cmd-CkVD1uOh.mjs → build-cmd-sI18tX_O.mjs} +3 -3
- package/dist/{cache-QcUfR-ff.mjs → cache-IHn5MwBC.mjs} +3 -3
- package/dist/{cancel-deploy-DsvWKFTe.mjs → cancel-deploy-C5qTOdLi.mjs} +3 -3
- package/dist/cf-access-DRsQRe6k.mjs +75 -0
- package/dist/cli/cli.mjs +364 -1883
- package/dist/cli/env-schema-probe.mjs +11 -2
- package/dist/{client-CyCHWSO_.mjs → client-dHfSJvAN.mjs} +336 -58
- package/dist/{cloudflare-auth-DRkGe7-s.mjs → cloudflare-auth-6M5llVPC.mjs} +53 -6
- package/dist/{cloudflare-cmd-D3ME2GAe.mjs → cloudflare-cmd-4RPGN3KB.mjs} +2 -2
- package/dist/{cloudflare-connect-BivMefMA.mjs → cloudflare-connect-t1UU5svD.mjs} +2 -2
- package/dist/{cloudflare-operations-CPTpRW6d.mjs → cloudflare-operations-BzWnlC1_.mjs} +1 -1
- package/dist/{config-BQFq7QvD.mjs → config-uNGuFsI2.mjs} +1 -1
- package/dist/{connect-D9Yr-T5f.mjs → connect-Bfk31O_8.mjs} +6 -6
- package/dist/{create-project-DMV-csEm.mjs → create-project-Bk9Z0-Jg.mjs} +6 -5
- package/dist/{db-BxoUWL0F.mjs → db-BkRoptAt.mjs} +75 -48
- package/dist/{delete-iJOqxBz1.mjs → delete-DouASY9P.mjs} +3 -3
- package/dist/{deploy-CcDoeBIf.mjs → deploy-DTaWUS1S.mjs} +137 -128
- package/dist/{dev-inbox-DkgRWLkW.mjs → dev-inbox-P0u4tM8Y.mjs} +1 -1
- package/dist/{domain-gu_iaHmn.mjs → domain-1RhhOVrC.mjs} +4 -4
- package/dist/email-C-lGh51B.mjs +795 -0
- package/dist/{env-D4Emu-M_.mjs → env-DBKmK4vc.mjs} +1 -0
- package/dist/{env-DmU2To0C.mjs → env-DJHsPE7Z.mjs} +5 -5
- package/dist/{env-validation-ENpMy6Ez.mjs → env-validation-CF6KvTRf.mjs} +3 -1
- package/dist/{gen-BKw6qHIg.mjs → gen-B_wPnVTK.mjs} +3 -3
- package/dist/generate-RTK8_kK1.mjs +47 -0
- package/dist/{github-cmd-DJS5Ab-H.mjs → github-cmd-PW7ZnWTp.mjs} +3 -3
- package/dist/{headers-D8QfRX9Y.mjs → headers-BAHwgHdW.mjs} +1 -1
- package/dist/help-CwOX-zmI.mjs +2216 -0
- package/dist/{inbound-BJ70in1n.d.mts → inbound-CH5Mksyy.d.mts} +37 -42
- package/dist/{inbound-CNKxb3FY.mjs → inbound-aVHEUhKo.mjs} +143 -101
- package/dist/index.mjs +69 -30
- package/dist/{init-Dx5cgKqK.mjs → init-BWZ7q5Z4.mjs} +11 -11
- package/dist/{link-D_rm5sRH.mjs → link-Rmvu2Wl_.mjs} +4 -4
- package/dist/{list-M8XShti7.mjs → list-DEE2S6mY.mjs} +4 -4
- package/dist/{local-d1-BE8KBbMy.mjs → local-d1-D2I6Ox5F.mjs} +1 -1
- package/dist/{login-C8-UuLnp.mjs → login-Uvferzmm.mjs} +28 -10
- package/dist/{logs-DwFU7dMW.mjs → logs-27FenuiC.mjs} +4 -4
- package/dist/{mime-BJD7d_qL.mjs → mime-D5Nmdzf7.mjs} +23 -9
- package/dist/{node-BkaXcpAc.mjs → node-Ez5KW5rn.mjs} +3 -3
- package/dist/operator-auth-B3e08unv.mjs +52 -0
- package/dist/operator-client-LUZnlnYk.mjs +82 -0
- package/dist/{operator-cmd-DYWRbWUA.mjs → operator-cmd-CjOTmAYE.mjs} +35 -55
- package/dist/{output-tFQLLj26.mjs → output-B0cfNSx5.mjs} +316 -2
- package/dist/pages/index.mjs +2 -2
- package/dist/platform-auth-config-DrbQXXiW.mjs +368 -0
- package/dist/platform-auth-protection-Bhtvp0B_.mjs +219 -0
- package/dist/platform-auth-recovery-CeOKGVeJ.mjs +310 -0
- package/dist/{platform-cmd-BEOVv1PN.mjs → platform-cmd-BFhieCdV.mjs} +16 -6
- package/dist/{platform-domain-BszUfiS8.mjs → platform-domain-C74PULqV.mjs} +4 -4
- package/dist/{platform-lifecycle-CjSr6xf0.mjs → platform-lifecycle-BwAIgz-t.mjs} +1667 -191
- package/dist/{platform-management-W30iCaTL.mjs → platform-management-COogu_Se.mjs} +33 -7
- package/dist/{platform-recovery-pHHv4ZSg.mjs → platform-recovery-ewqLefp1.mjs} +6 -5
- package/dist/{prepare-CBetXvsN.mjs → prepare-CtDJjoOj.mjs} +2 -2
- package/dist/{prepare-BfJvFUtJ.mjs → prepare-blNRQvQl.mjs} +2 -2
- package/dist/prerender-render.d.mts +11 -0
- package/dist/prerender-render.mjs +111 -0
- package/dist/{project-cmd-DCpk1cDt.mjs → project-cmd-DmZK9Hxf.mjs} +32 -14
- package/dist/project-team-D8jOJMUJ.mjs +130 -0
- package/dist/project-token-DA34bf-C.mjs +75 -0
- package/dist/{provision-Blnstcm2.mjs → provision-CSJOjjQk.mjs} +2 -0
- package/dist/{requests-Dn8Vheh1.mjs → requests-CUExwGQQ.mjs} +3 -3
- package/dist/{rollback-CtlPXEBi.mjs → rollback-CDNGU1gr.mjs} +4 -4
- package/dist/{runner-mysql-CgRFl3s6.mjs → runner-mysql-7BPUNGmL.mjs} +1 -1
- package/dist/{runner-pg-DCkWPsWS.mjs → runner-pg-BkEza-dX.mjs} +1 -1
- package/dist/runtime/ai.mjs +3 -2
- package/dist/runtime/email/testing.d.mts +1 -1
- package/dist/runtime/email/testing.mjs +3 -3
- package/dist/runtime/email-protocol.d.mts +15 -0
- package/dist/runtime/email-protocol.mjs +70 -0
- package/dist/runtime/email.d.mts +2 -2
- package/dist/runtime/email.mjs +189 -96
- package/dist/runtime/remote/index.mjs +5 -3
- package/dist/{secret-BqTxGqki.mjs → secret-Bzzi2e9E.mjs} +5 -5
- package/dist/{skills-Q46GZMO-.mjs → skills-C0RvGjeE.mjs} +1 -1
- package/dist/sqlite-validation-BzKMWnO4.mjs +25 -0
- package/dist/{subcommand-prompt-WfySCQ7S.mjs → subcommand-prompt-Bmyn5Rlc.mjs} +1 -1
- package/dist/{validate-Bihr8WBi.mjs → validate-tBBN_dXH.mjs} +1 -0
- package/package.json +12 -7
- package/skills/void/SKILL.md +35 -4
- package/skills/void/docs/guide/deployment.md +2 -0
- package/skills/void/docs/guide/email.md +121 -98
- package/skills/void/docs/guide/platform-administration.md +214 -2
- package/skills/void/docs/guide/platform-development.md +93 -2
- package/skills/void/docs/guide/project-collaboration.md +94 -0
- package/skills/void/docs/guide/self-hosted-platform.md +184 -30
- package/skills/void/docs/reference/cli.md +294 -15
- package/dist/cf-access-AJ1ehiFR.mjs +0 -42
- package/dist/cf-access-DsSsZUPr.mjs +0 -67
- package/dist/email-r6DHJyAB.mjs +0 -262
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { c as relative, o as join } from "./dist-BrsS7cai.mjs";
|
|
2
|
-
import { D as log, p as getPackageDir } from "./output-
|
|
2
|
+
import { D as log, p as getPackageDir } from "./output-B0cfNSx5.mjs";
|
|
3
3
|
import { existsSync, mkdirSync, readFileSync, readdirSync, readlinkSync, symlinkSync } from "node:fs";
|
|
4
4
|
//#region src/cli/skills.ts
|
|
5
5
|
function parseSkills(skillsDir) {
|
|
@@ -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 };
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { S as S_STEP_SUBMIT, _ as S_CHECKBOX_INACTIVE, b as S_RADIO_INACTIVE, c as import_picocolors, g as S_CHECKBOX_ACTIVE, h as S_BAR_END, m as S_BAR, x as S_STEP_ACTIVE, y as S_RADIO_ACTIVE, z as wrapAnsi } from "./output-B0cfNSx5.mjs";
|
|
2
2
|
//#region src/cli/subcommand-prompt.ts
|
|
3
3
|
function promptSubcommand(command, subcommands) {
|
|
4
4
|
return new Promise((resolve) => {
|
|
@@ -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.
|
|
3
|
+
"version": "0.20.2",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "git+https://github.com/voidzero-dev/void.git",
|
|
@@ -218,6 +218,11 @@
|
|
|
218
218
|
"import": "./dist/runtime/email.mjs",
|
|
219
219
|
"require": "./dist/runtime/email.mjs"
|
|
220
220
|
},
|
|
221
|
+
"./_email-protocol": {
|
|
222
|
+
"types": "./dist/runtime/email-protocol.d.mts",
|
|
223
|
+
"import": "./dist/runtime/email-protocol.mjs",
|
|
224
|
+
"require": "./dist/runtime/email-protocol.mjs"
|
|
225
|
+
},
|
|
221
226
|
"./email/testing": {
|
|
222
227
|
"types": "./dist/runtime/email/testing.d.mts",
|
|
223
228
|
"import": "./dist/runtime/email/testing.mjs",
|
|
@@ -313,11 +318,11 @@
|
|
|
313
318
|
"@cloudflare/vite-plugin": "1.54.7",
|
|
314
319
|
"@cloudflare/workers-types": "^4.20260702.1",
|
|
315
320
|
"@napi-rs/keyring": "2.0.0",
|
|
316
|
-
"@void/deploy-cloudflare": "0.20.
|
|
317
|
-
"@void/deploy-core": "0.20.
|
|
318
|
-
"@void/edge": "0.20.
|
|
319
|
-
"@void/isr": "0.20.
|
|
320
|
-
"@void/platform": "0.20.
|
|
321
|
+
"@void/deploy-cloudflare": "0.20.2",
|
|
322
|
+
"@void/deploy-core": "0.20.2",
|
|
323
|
+
"@void/edge": "0.20.2",
|
|
324
|
+
"@void/isr": "0.20.2",
|
|
325
|
+
"@void/platform": "0.20.2",
|
|
321
326
|
"better-auth": "^1.7.3",
|
|
322
327
|
"better-sqlite3": "^13.0.3",
|
|
323
328
|
"blake3-jit": "^1.1.0",
|
|
@@ -363,7 +368,7 @@
|
|
|
363
368
|
"zod": "^4.6.1"
|
|
364
369
|
},
|
|
365
370
|
"peerDependencies": {
|
|
366
|
-
"@void/md": "0.20.
|
|
371
|
+
"@void/md": "0.20.2",
|
|
367
372
|
"arktype": ">=2.0.0",
|
|
368
373
|
"valibot": ">=1.0.0-beta.7",
|
|
369
374
|
"vite": "^8.0.0",
|
package/skills/void/SKILL.md
CHANGED
|
@@ -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,
|
|
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, 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.
|
|
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;
|
|
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. 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. 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.
|
|
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.
|
|
@@ -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:
|
|
@@ -19,7 +19,7 @@ const result = await sendEmail({
|
|
|
19
19
|
|
|
20
20
|
if (!result.ok) {
|
|
21
21
|
if ('error' in result) {
|
|
22
|
-
//
|
|
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.
|
|
@@ -66,28 +66,61 @@ 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
|
-
##
|
|
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` 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
|
+
|
|
82
|
+
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
|
+
|
|
84
|
+
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.
|
|
73
85
|
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
86
|
+
`void email domain status <domain>` reports three independent results:
|
|
87
|
+
|
|
88
|
+
- **Inbound**: whether mail can reach the project's handlers.
|
|
89
|
+
- **Outbound**: whether sending is ready, restricted to verified destinations, pending, or blocked.
|
|
90
|
+
- **Management**: whether the stored Cloudflare credential can manage the connection.
|
|
91
|
+
|
|
92
|
+
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
|
+
|
|
94
|
+
After the last assignment's ingress is deleted, the platform forgets its stored
|
|
95
|
+
credential. Revoke a token you no longer use in Cloudflare; Void does not revoke
|
|
96
|
+
tokens you created yourself. Re-adding a removed domain requires a scoped token
|
|
97
|
+
again. A domain can move to another project only after its removal completes
|
|
98
|
+
and the zone connection's manager authorizes the new assignment.
|
|
99
|
+
|
|
100
|
+
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.
|
|
101
|
+
|
|
102
|
+
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.
|
|
103
|
+
|
|
104
|
+
## Options
|
|
85
105
|
|
|
86
|
-
|
|
106
|
+
| Option | Type | Notes |
|
|
107
|
+
| ---------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
|
108
|
+
| `from` | `string \| { email, name? }` | Optional. Pinned to the project sender — see below. |
|
|
109
|
+
| `to` | `Address \| Address[]` | Required. One or more recipients. |
|
|
110
|
+
| `subject` | `string` | Required. UTF-8 supported (encoded as RFC 2047). |
|
|
111
|
+
| `text` | `string` | At least one of `text` / `html` is required. |
|
|
112
|
+
| `html` | `string` | Sent as `multipart/alternative` if both are provided. |
|
|
113
|
+
| `replyTo` | `Address` | Optional `Reply-To` header. |
|
|
114
|
+
| `cc`, `bcc` | `Address \| Address[]` | Optional. Each recipient is sent its own message envelope. |
|
|
115
|
+
| `headers` | `Record<string, string>` | Custom headers; reserved headers (From, Date, etc.) are ignored. |
|
|
116
|
+
| `attachments` | `Attachment[]` | See [Attachments](#attachments). |
|
|
117
|
+
| `idempotencyKey` | `string` | Optional on the Void Platform: 1–128 printable, non-space ASCII characters. Reuse for retries of the same send. |
|
|
118
|
+
|
|
119
|
+
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.
|
|
87
120
|
|
|
88
121
|
`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
122
|
|
|
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.
|
|
123
|
+
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
124
|
|
|
92
125
|
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
126
|
|
|
@@ -129,66 +162,52 @@ await sendEmail({
|
|
|
129
162
|
});
|
|
130
163
|
```
|
|
131
164
|
|
|
132
|
-
`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
|
|
165
|
+
`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.
|
|
133
166
|
|
|
134
167
|
## Result and errors
|
|
135
168
|
|
|
136
|
-
`sendEmail` returns a
|
|
169
|
+
`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.
|
|
137
170
|
|
|
138
|
-
|
|
139
|
-
interface SendEmailDelivery {
|
|
140
|
-
recipient: string; // the envelope `To` used for this CF send
|
|
141
|
-
messageId: string;
|
|
142
|
-
}
|
|
143
|
-
|
|
144
|
-
type SendEmailRecipientResult =
|
|
145
|
-
| { recipient: string; ok: true; messageId: string }
|
|
146
|
-
| { recipient: string; ok: false; error: SendEmailError };
|
|
147
|
-
|
|
148
|
-
type SendEmailResult =
|
|
149
|
-
| { ok: true; ids: SendEmailDelivery[] }
|
|
150
|
-
| { ok: false; error: SendEmailError } // pre-flight failure
|
|
151
|
-
| { ok: false; deliveries: SendEmailRecipientResult[] }; // mid-batch failure
|
|
152
|
-
```
|
|
171
|
+
`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.
|
|
153
172
|
|
|
154
|
-
`
|
|
173
|
+
`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.
|
|
155
174
|
|
|
156
|
-
|
|
175
|
+
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.
|
|
157
176
|
|
|
158
|
-
|
|
159
|
-
- **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.
|
|
160
|
-
- **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`.
|
|
177
|
+
Use an idempotency key for sends you may retry:
|
|
161
178
|
|
|
162
179
|
```ts
|
|
163
|
-
const result = await sendEmail({
|
|
180
|
+
const result = await sendEmail({
|
|
181
|
+
to: 'user@example.com',
|
|
182
|
+
subject: 'Invoice ready',
|
|
183
|
+
text: 'Your invoice is available in your account.',
|
|
184
|
+
idempotencyKey: 'invoice:42:ready',
|
|
185
|
+
});
|
|
186
|
+
|
|
164
187
|
if (result.ok) {
|
|
165
|
-
|
|
166
|
-
} else if ('error' in result) {
|
|
167
|
-
// pre-flight failure — retry the whole call
|
|
188
|
+
console.log('Accepted by the provider', result.operationId);
|
|
168
189
|
} else {
|
|
169
|
-
|
|
170
|
-
const toRetry = result.deliveries.filter((d) => !d.ok).map((d) => d.recipient);
|
|
190
|
+
console.log('Inspect the outcome before retrying', result.operationId, result);
|
|
171
191
|
}
|
|
172
192
|
```
|
|
173
193
|
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
| Code | Meaning
|
|
177
|
-
| ------------------------ |
|
|
178
|
-
| `BINDING_MISSING` | No email transport
|
|
179
|
-
| `INVALID_FROM` |
|
|
180
|
-
| `INVALID_TO` |
|
|
181
|
-
| `UNVERIFIED_DESTINATION` |
|
|
182
|
-
| `MIME_ERROR` |
|
|
183
|
-
| `QUOTA_EXCEEDED` |
|
|
184
|
-
| `
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
way. Narrow with `'error' in result` rather than assuming.
|
|
194
|
+
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.
|
|
195
|
+
|
|
196
|
+
| Code | Meaning |
|
|
197
|
+
| ------------------------ | -------------------------------------------------------------- |
|
|
198
|
+
| `BINDING_MISSING` | No email transport is configured. |
|
|
199
|
+
| `INVALID_FROM` | The sender is invalid or not authorized for the project. |
|
|
200
|
+
| `INVALID_TO` | The recipient list is invalid or exceeds 50 recipients. |
|
|
201
|
+
| `UNVERIFIED_DESTINATION` | The recipient needs verification under the active policy. |
|
|
202
|
+
| `MIME_ERROR` | The message is invalid or exceeds a size limit. |
|
|
203
|
+
| `QUOTA_EXCEEDED` | A platform or provider quota has been reached. |
|
|
204
|
+
| `IDEMPOTENCY_CONFLICT` | The key was already used for another payload. |
|
|
205
|
+
| `OUTCOME_UNKNOWN` | The send may have reached the provider; do not blindly resend. |
|
|
206
|
+
| `UPSTREAM_ERROR` | The provider or platform refused the request. |
|
|
207
|
+
|
|
208
|
+
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.
|
|
209
|
+
|
|
210
|
+
`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.
|
|
192
211
|
|
|
193
212
|
## Local development
|
|
194
213
|
|
|
@@ -343,47 +362,49 @@ export default defineEmail(async (message, env, ctx, info) => {
|
|
|
343
362
|
|
|
344
363
|
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.
|
|
345
364
|
|
|
346
|
-
On the platform
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
absent and you omitted `from`, rather than guessing a sender. With no slug at
|
|
351
|
-
all — a worker on your own Cloudflare account — the reply is sent from
|
|
352
|
-
`message.to`, the address on your own zone the message was delivered to.
|
|
365
|
+
On the platform, shared-domain replies default to
|
|
366
|
+
`<slug>+noreply@<mail domain>`. For mail received at a registered custom domain,
|
|
367
|
+
replies default to the address that received the message. `replyEmail` throws
|
|
368
|
+
when it cannot determine a sender. Native Cloudflare replies default to `message.to`.
|
|
353
369
|
|
|
354
370
|
::: warning The platform pins the reply sender
|
|
355
|
-
You may override `from
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
This is what stops one project replying as another project's address. Note that
|
|
362
|
-
`message.to` is the _stripped_ address (`<tag>@<domain>`), so `from: message.to`
|
|
363
|
-
is exactly the shape the pin rejects.
|
|
371
|
+
You may override `from` with your project's shared address or an address on
|
|
372
|
+
a registered custom domain your project owns. An unauthorized sender is rejected
|
|
373
|
+
and recorded in `void email logs`.
|
|
374
|
+
|
|
375
|
+
For shared addressing, `message.to` has the project prefix removed. Use the
|
|
376
|
+
default sender instead of copying that stripped address into `from`.
|
|
364
377
|
:::
|
|
365
378
|
|
|
366
379
|
::: warning The platform gates the reply recipient
|
|
367
|
-
A reply's `to` goes through the
|
|
368
|
-
|
|
369
|
-
`void email allow <address
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
(`--platform cloudflare`), where `reply()` is Cloudflare's own reply-to-sender
|
|
378
|
-
and Void adds no allowlist.
|
|
380
|
+
A reply's `to` goes through the platform's recipient policy and the transport's
|
|
381
|
+
capability checks. Shared sending defaults to your project's verified
|
|
382
|
+
destinations: run `void email allow <address>` and have the recipient complete
|
|
383
|
+
verification. A blocked reply is recorded in `void email logs`; the inbound
|
|
384
|
+
message remains accepted.
|
|
385
|
+
|
|
386
|
+
Custom-domain sends may reach external recipients when Cloudflare Sending is
|
|
387
|
+
enabled. Addresses on the platform's own mail domain always require per-project
|
|
388
|
+
consent. Native forwarding and reply operations can have additional Cloudflare
|
|
389
|
+
restrictions; check the recorded outcome before assuming a reply was accepted.
|
|
379
390
|
:::
|
|
380
391
|
|
|
381
|
-
|
|
382
|
-
|
|
392
|
+
In a platform handler, awaiting `replyEmail` or `message.forward` records an
|
|
393
|
+
action for execution after the handler finishes. Use `void email logs` to see
|
|
394
|
+
its provider outcome. `sendEmail` returns its send result directly.
|
|
395
|
+
|
|
396
|
+
`message.forward` requires a native Cloudflare email event. A message relayed
|
|
397
|
+
from a customer zone cannot use native forwarding; its forward action is
|
|
398
|
+
recorded as `UNSUPPORTED_ACTION` without sending or consuming quota. Replies
|
|
399
|
+
remain available through that domain's sending capability.
|
|
383
400
|
|
|
384
401
|
### Configuring inbound delivery
|
|
385
402
|
|
|
386
|
-
|
|
403
|
+
On an email-enabled platform, `<slug>+anything@<mail domain>` reaches your
|
|
404
|
+
deployed `email/` handlers without per-project DNS setup. A
|
|
405
|
+
[registered custom domain](#your-own-domain-on-the-platform) reaches those
|
|
406
|
+
handlers once its inbound readiness is `ready`. Use `void email domain status`
|
|
407
|
+
to check current provider routing and any remaining setup steps.
|
|
387
408
|
|
|
388
409
|
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
410
|
|
|
@@ -447,7 +468,7 @@ describe('email handlers', () => {
|
|
|
447
468
|
});
|
|
448
469
|
```
|
|
449
470
|
|
|
450
|
-
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
|
|
471
|
+
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.
|
|
451
472
|
|
|
452
473
|
## Your own Cloudflare account
|
|
453
474
|
|
|
@@ -503,7 +524,9 @@ Enter (Yes is the default) applies only the `+` rows that are not ready, then th
|
|
|
503
524
|
outbound sendEmail() from support@mail.acme.com
|
|
504
525
|
```
|
|
505
526
|
|
|
506
|
-
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.
|
|
527
|
+
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.
|
|
528
|
+
|
|
529
|
+
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.
|
|
507
530
|
|
|
508
531
|
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)`.
|
|
509
532
|
|
|
@@ -550,7 +573,7 @@ How handlers become addresses, with `email.from` on `mail.acme.com` under the zo
|
|
|
550
573
|
| `email/_default.ts` | **none** — a catch-all exists only on an apex | `*@acme.com` (the catch-all) |
|
|
551
574
|
| `email/[user].ts`, `email/[user]+[tag].ts` (dynamic local part) | **refused** — a dynamic local part needs a catch-all | `*@acme.com` (the catch-all) |
|
|
552
575
|
|
|
553
|
-
On a subdomain, `email/_default.ts` gets no rule
|
|
576
|
+
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.
|
|
554
577
|
|
|
555
578
|
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.
|
|
556
579
|
|
|
@@ -581,9 +604,9 @@ deploy: email on mail.acme.com is not set up, and this shell cannot ask.
|
|
|
581
604
|
Run `void email setup --platform cloudflare` once locally, commit wrangler.jsonc, then redeploy — deploying without email.
|
|
582
605
|
```
|
|
583
606
|
|
|
584
|
-
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`.
|
|
607
|
+
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`.
|
|
585
608
|
|
|
586
|
-
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`.
|
|
609
|
+
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)).
|
|
587
610
|
|
|
588
611
|
### If the subdomain step is refused
|
|
589
612
|
|