void 0.10.12 → 0.20.0
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/AGENTS_TEMPLATE.md +6 -0
- package/{skills/void/docs/node_modules/void/node_modules/pglite-server/LICENSE.md → LICENSE} +1 -1
- package/README.md +34 -8
- package/dist/{auth-qgMlYp7Z.d.mts → auth-DkcFflXV.d.mts} +10 -11
- package/dist/auth-W9WII-mN.mjs +630 -0
- package/dist/{auth-cmd-BqsdZJp5.mjs → auth-cmd-CYVhSNFy.mjs} +4 -3
- package/dist/{auth-migrations-BTZ-ATvQ.mjs → auth-migrations-9vif1uj8.mjs} +53 -12
- package/dist/{better-auth-shared-BQooDxbw.mjs → better-auth-shared-CYw1T3k4.mjs} +7 -10
- package/dist/{better-auth-shared-DealXecJ.d.mts → better-auth-shared-DSCeohOK.d.mts} +10 -5
- package/dist/{build-cmd-Bujrv5q-.mjs → build-cmd-CkVD1uOh.mjs} +6 -4
- package/dist/{cache-C11V8Fxq.mjs → cache-QcUfR-ff.mjs} +9 -5
- package/dist/{cancel-deploy-fwFYF04b.mjs → cancel-deploy-DsvWKFTe.mjs} +3 -2
- package/dist/cf-access-AJ1ehiFR.mjs +42 -0
- package/dist/cf-access-DsSsZUPr.mjs +67 -0
- package/dist/cli/cli.d.mts +1 -1
- package/dist/cli/cli.mjs +1592 -153
- package/dist/cli/env-schema-probe.d.mts +48 -61
- package/dist/cli/env-schema-probe.mjs +10 -58
- package/dist/{client-Gb71-XkG.mjs → client-CyCHWSO_.mjs} +61 -463
- package/dist/cloudflare-auth-DRkGe7-s.mjs +170 -0
- package/dist/cloudflare-cmd-D3ME2GAe.mjs +62 -0
- package/dist/cloudflare-connect-BivMefMA.mjs +56 -0
- package/dist/cloudflare-operations-CPTpRW6d.mjs +566 -0
- package/dist/{config-BkTvs43g.mjs → config-BQFq7QvD.mjs} +85 -41
- package/dist/{config-CutEMNGJ.mjs → config-NOG_U1aK.mjs} +10 -15
- package/dist/connect-D9Yr-T5f.mjs +79 -0
- package/dist/{create-project-DsYvl3TB.mjs → create-project-DMV-csEm.mjs} +26 -16
- package/dist/database-provider.d.mts +21 -0
- package/dist/database-provider.mjs +6 -0
- package/dist/{db-DOiJMRt2.mjs → db-BxoUWL0F.mjs} +600 -115
- package/dist/{delete-mh6p-zkQ.mjs → delete-iJOqxBz1.mjs} +9 -6
- package/dist/{deploy-u7Rv9q_q.mjs → deploy-CcDoeBIf.mjs} +3451 -1986
- package/dist/dev-inbox-DkgRWLkW.mjs +307 -0
- package/dist/{discover-CJHyvYfR.mjs → discover-xvfrgJeo.mjs} +3 -3
- package/dist/{magic-string.es-ZQjdJFFn.mjs → dist-BR1quN_w.mjs} +570 -216
- package/dist/{dist-DaKKDf8D.mjs → dist-BrsS7cai.mjs} +17 -2
- package/dist/{dist-BuiRJkTd.mjs → dist-m40_XgNh.mjs} +48 -33
- package/dist/{domain-DiaNQbrl.mjs → domain-gu_iaHmn.mjs} +41 -4
- package/dist/dotenv-VQxupEUv.mjs +181 -0
- package/dist/edge.d.mts +2 -0
- package/dist/edge.mjs +2 -0
- package/dist/email-Ce6SQq-i.mjs +182 -0
- package/dist/email-r6DHJyAB.mjs +262 -0
- package/dist/{entry-D7yy4xVH.mjs → entry-DU3oDoQ3.mjs} +2 -2
- package/dist/env-D4Emu-M_.mjs +95 -0
- package/dist/env-DmU2To0C.mjs +73 -0
- package/dist/env-helpers-CyKtOBpj.d.mts +22 -0
- package/dist/env-public-D_6u46fX.d.mts +135 -0
- package/dist/{env-types-QBj-ndax.mjs → env-types-BNPhro-M.mjs} +2 -2
- package/dist/{env-validation-Dea3v3ej.mjs → env-validation-ENpMy6Ez.mjs} +68 -122
- package/dist/{gen-o_w-8yI8.mjs → gen-BKw6qHIg.mjs} +70 -15
- package/dist/{github-cmd-DKcGUNsj.mjs → github-cmd-DJS5Ab-H.mjs} +39 -31
- package/dist/{handler-Cjh8uM3Y.d.mts → handler-D1hLsObx.d.mts} +124 -122
- package/dist/{head-nmvOgFjd.d.mts → head-Do8P4puT.d.mts} +9 -8
- package/dist/{headers-nsHIFixA.mjs → headers-D8QfRX9Y.mjs} +12 -10
- package/dist/inbound-BJ70in1n.d.mts +354 -0
- package/dist/inbound-CNKxb3FY.mjs +694 -0
- package/dist/index.d.mts +61 -34
- package/dist/index.mjs +1048 -321
- package/dist/{init-3rgHKBVi.mjs → init-Dx5cgKqK.mjs} +329 -271
- package/dist/link-D_rm5sRH.mjs +52 -0
- package/dist/{list-CPwFDZ_c.mjs → list-M8XShti7.mjs} +37 -5
- package/dist/{runner-kapo9aPs.mjs → local-d1-BE8KBbMy.mjs} +116 -6
- package/dist/{login-BT3H8PN3.mjs → login-C8-UuLnp.mjs} +17 -15
- package/dist/{logs-Bt313ax7.mjs → logs-DwFU7dMW.mjs} +16 -2
- package/dist/mime-BJD7d_qL.mjs +1216 -0
- package/dist/neon-DHwd2zvC.mjs +54 -0
- package/dist/{node-Da0UcsGA.mjs → node-BkaXcpAc.mjs} +29 -8
- package/dist/operator-cmd-DYWRbWUA.mjs +348 -0
- package/dist/{agents-CtgBYqld.mjs → output-tFQLLj26.mjs} +676 -211
- package/dist/{package-json-Cx1osYo6.mjs → package-json-CPoWX79C.mjs} +1 -1
- package/dist/pages/client.d.mts +43 -41
- package/dist/pages/client.mjs +40 -33
- package/dist/pages/head-client.d.mts +10 -12
- package/dist/pages/head.d.mts +1 -1
- package/dist/pages/index.d.mts +37 -14
- package/dist/pages/index.mjs +5 -5
- package/dist/pages/islands-plugin.d.mts +25 -27
- package/dist/pages/islands-plugin.mjs +6 -4
- package/dist/pages/prefetch.d.mts +6 -7
- package/dist/pages/protocol.d.mts +2 -2
- package/dist/pages/protocol.mjs +2 -1
- package/dist/pages/serialize.d.mts +7 -8
- package/dist/platform-cmd-BEOVv1PN.mjs +123 -0
- package/dist/platform-domain-BszUfiS8.mjs +228 -0
- package/dist/platform-lifecycle-CjSr6xf0.mjs +4450 -0
- package/dist/platform-management-W30iCaTL.mjs +698 -0
- package/dist/platform-recovery-pHHv4ZSg.mjs +99 -0
- package/dist/{plugin-inference-DMeavIJ6.mjs → plugin-inference-BDRfZngg.mjs} +35 -19
- package/dist/prepare-BfJvFUtJ.mjs +14 -0
- package/dist/{prepare-BoKHgMNx.mjs → prepare-CBetXvsN.mjs} +15 -24
- package/dist/{preset-BjyR3lzz.mjs → preset-lAy0B0BQ.mjs} +25 -212
- package/dist/{project-cmd-D_w-4w5B.mjs → project-cmd-DCpk1cDt.mjs} +25 -12
- package/dist/{project-paths-BQd7OmIo.mjs → project-paths-SK8nMHPp.mjs} +3 -1
- package/dist/{project-tsconfig-B-QtXjLQ.mjs → project-tsconfig-Ql2XsSQp.mjs} +2 -2
- package/dist/{protocol-Bnb0LFp3.d.mts → protocol-C-pqYJjE.d.mts} +3 -4
- package/dist/{provision-rShh6MKY.mjs → provision-Blnstcm2.mjs} +78 -45
- package/dist/r2-conditions-D1Wk8i7b.mjs +30 -0
- package/dist/{requests-B8sZxaFM.mjs → requests-Dn8Vheh1.mjs} +5 -3
- package/dist/{resolve-project-BBMtLLV9.mjs → resolve-project--Vxawf7z.mjs} +2 -2
- package/dist/rollback-CtlPXEBi.mjs +166 -0
- package/dist/{rolldown-runtime-DJK8HYOj.mjs → rolldown-runtime-rQ84J-ij.mjs} +1 -1
- package/dist/{route-types-COI2DsZv.mjs → route-types-Da-DpyUp.mjs} +85 -26
- package/dist/routes-stub.d.mts +22 -23
- package/dist/runner-mysql-CgRFl3s6.mjs +61 -0
- package/dist/{runner-pg-CHM76xuC.mjs → runner-pg-DCkWPsWS.mjs} +18 -6
- package/dist/runtime/ai.d.mts +21 -14
- package/dist/runtime/ai.mjs +5 -4
- package/dist/runtime/auth-client-react.d.mts +3 -5
- package/dist/runtime/auth-client-solid.d.mts +3 -5
- package/dist/runtime/auth-client-svelte.d.mts +3 -5
- package/dist/runtime/auth-client-vue.d.mts +3 -5
- package/dist/runtime/auth-client.d.mts +3 -5
- package/dist/runtime/auth.d.mts +1 -1
- package/dist/runtime/better-auth-mysql.d.mts +10 -0
- package/dist/runtime/better-auth-mysql.mjs +49 -0
- package/dist/runtime/better-auth-pg.d.mts +8 -9
- package/dist/runtime/better-auth-pg.mjs +2 -2
- package/dist/runtime/better-auth.d.mts +8 -9
- package/dist/runtime/better-auth.mjs +2 -2
- package/dist/runtime/client-react.d.mts +1 -1
- package/dist/runtime/client-solid.d.mts +1 -1
- package/dist/runtime/client-svelte.d.mts +1 -1
- package/dist/runtime/client-vue.d.mts +1 -1
- package/dist/runtime/client.d.mts +1 -1
- package/dist/runtime/db-mysql.d.mts +2 -0
- package/dist/runtime/db-mysql.mjs +1 -0
- package/dist/runtime/db.d.mts +10 -11
- package/dist/runtime/durable.d.mts +47 -0
- package/dist/runtime/durable.mjs +146 -0
- package/dist/runtime/email/testing.d.mts +112 -0
- package/dist/runtime/email/testing.mjs +283 -0
- package/dist/runtime/email.d.mts +30 -0
- package/dist/runtime/email.mjs +573 -0
- package/dist/runtime/env-helpers.d.mts +2 -2
- package/dist/runtime/env-helpers.mjs +5 -42
- package/dist/runtime/env-public-client.d.mts +10 -11
- package/dist/runtime/env-public-client.mjs +1 -1
- package/dist/runtime/env-public.d.mts +2 -2
- package/dist/runtime/env-public.mjs +104 -49
- package/dist/runtime/env.d.mts +19 -18
- package/dist/runtime/env.mjs +15 -2
- package/dist/runtime/fetch-stream.d.mts +20 -21
- package/dist/runtime/fetch.d.mts +15 -16
- package/dist/runtime/handler.d.mts +1 -1
- package/dist/runtime/isr.d.mts +21 -22
- package/dist/runtime/isr.mjs +26 -8
- package/dist/runtime/kv.d.mts +9 -10
- package/dist/runtime/live-client.d.mts +5 -7
- package/dist/runtime/live-client.mjs +9 -7
- package/dist/runtime/live-server.d.mts +4 -5
- package/dist/runtime/live.d.mts +22 -24
- package/dist/runtime/live.mjs +1 -1
- package/dist/runtime/log.d.mts +16 -17
- package/dist/runtime/migration-handler-mysql.d.mts +4 -0
- package/dist/runtime/migration-handler-mysql.mjs +81 -0
- package/dist/runtime/migration-handler-pg.d.mts +2 -4
- package/dist/runtime/migration-handler.d.mts +5 -6
- package/dist/runtime/migration-handler.mjs +4 -3
- package/dist/runtime/queues.d.mts +3 -4
- package/dist/runtime/queues.mjs +2 -1
- package/dist/runtime/remote/binding-handler.d.mts +10 -12
- package/dist/runtime/remote/binding-handler.mjs +24 -3
- package/dist/runtime/remote/index.d.mts +5 -6
- package/dist/runtime/remote/index.mjs +21 -18
- package/dist/runtime/response.d.mts +10 -11
- package/dist/runtime/sandbox.d.mts +56 -55
- package/dist/runtime/sandbox.mjs +57 -49
- package/dist/runtime/schema-mysql.d.mts +1 -0
- package/dist/runtime/schema-mysql.mjs +2 -0
- package/dist/runtime/seed.d.mts +14 -9
- package/dist/runtime/sse-client.d.mts +6 -7
- package/dist/runtime/sse.d.mts +11 -12
- package/dist/runtime/storage.d.mts +3 -4
- package/dist/runtime/validator.d.mts +1 -1
- package/dist/runtime/ws-server.d.mts +12 -12
- package/dist/runtime/ws-server.mjs +32 -4
- package/dist/runtime/ws.d.mts +19 -21
- package/dist/{scan-DYXkrasO.mjs → scan-BMH4rzlv.mjs} +53 -31
- package/dist/{scan-DEwlM_Xy.mjs → scan-CpK-57ug.mjs} +9 -5
- package/dist/{secret-Dt32J6RI.mjs → secret-BqTxGqki.mjs} +62 -5
- package/dist/{skills-CLjN0uUO.mjs → skills-Q46GZMO-.mjs} +6 -4
- package/dist/{standard-schema-DJ0HW7QP.d.mts → standard-schema-Fo_vCAZh.d.mts} +6 -6
- package/dist/{subcommand-prompt-BzV8iQZo.mjs → subcommand-prompt-WfySCQ7S.mjs} +67 -48
- package/dist/sveltekit.d.mts +12 -11
- package/dist/sveltekit.mjs +1 -1
- package/dist/types-BAp5AEBU.d.mts +79 -0
- package/dist/types-CKWnYgfy.d.mts +1 -0
- package/dist/{validate-Cw_RLeTj.mjs → validate-Bihr8WBi.mjs} +3 -3
- package/dist/wrangler--imS8n0d.mjs +1796 -0
- package/dist/{yarn-pnp-DJn3SAHF.mjs → yarn-pnp-DxSInkzL.mjs} +1 -1
- package/package.json +79 -65
- package/schema.json +35 -3
- package/skills/migrate-vite-cloudflare-to-void/SKILL.md +1 -1
- package/skills/void/SKILL.md +59 -2
- package/skills/void/docs/guide/ai.md +32 -14
- package/skills/void/docs/guide/app-types.md +6 -6
- package/skills/void/docs/guide/auth.md +14 -16
- package/skills/void/docs/guide/database/d1.md +6 -0
- package/skills/void/docs/guide/database/mysql.md +60 -0
- package/skills/void/docs/guide/database/postgresql.md +14 -9
- package/skills/void/docs/guide/database.md +39 -26
- package/skills/void/docs/guide/deployment.md +163 -22
- package/skills/void/docs/guide/durable-state.md +140 -0
- package/skills/void/docs/guide/edge/headers.md +5 -5
- package/skills/void/docs/guide/edge/prerendering.md +2 -0
- package/skills/void/docs/guide/edge/revalidation.md +21 -6
- package/skills/void/docs/guide/edge/rewrites.md +18 -14
- package/skills/void/docs/guide/edge/static-assets.md +23 -8
- package/skills/void/docs/guide/email.md +609 -0
- package/skills/void/docs/guide/env-migration.md +109 -0
- package/skills/void/docs/guide/env-vars.md +70 -248
- package/skills/void/docs/guide/index.md +15 -17
- package/skills/void/docs/guide/jobs.md +8 -5
- package/skills/void/docs/guide/live.md +7 -15
- package/skills/void/docs/guide/pages-routing/actions-and-forms.md +8 -4
- package/skills/void/docs/guide/pages-routing/islands.md +3 -3
- package/skills/void/docs/guide/pages-routing/loaders.md +6 -4
- package/skills/void/docs/guide/pages-routing/overview.md +7 -7
- package/skills/void/docs/guide/platform-administration.md +212 -0
- package/skills/void/docs/guide/platform-development.md +235 -0
- package/skills/void/docs/guide/queues.md +10 -10
- package/skills/void/docs/guide/quickstart.md +46 -67
- package/skills/void/docs/guide/remote-dev.md +8 -6
- package/skills/void/docs/guide/sandboxes.md +33 -16
- package/skills/void/docs/guide/self-hosted-platform.md +553 -0
- package/skills/void/docs/guide/server-routing.md +25 -5
- package/skills/void/docs/guide/sse.md +4 -4
- package/skills/void/docs/guide/ssg.md +5 -3
- package/skills/void/docs/guide/storage.md +2 -2
- package/skills/void/docs/guide/websockets.md +18 -7
- package/skills/void/docs/index.md +3 -3
- package/skills/void/docs/integrations/agents.md +6 -64
- package/skills/void/docs/integrations/cloudflare.md +165 -146
- package/skills/void/docs/integrations/frameworks/analog.md +4 -4
- package/skills/void/docs/integrations/frameworks/astro.md +5 -5
- package/skills/void/docs/integrations/frameworks/nuxt.md +5 -5
- package/skills/void/docs/integrations/frameworks/overview.md +3 -3
- package/skills/void/docs/integrations/frameworks/react-router.md +3 -3
- package/skills/void/docs/integrations/frameworks/sveltekit.md +3 -3
- package/skills/void/docs/integrations/frameworks/tanstack-start.md +2 -2
- package/skills/void/docs/integrations/nodejs-bun-deno.md +11 -4
- package/skills/void/docs/reference/api.md +42 -6
- package/skills/void/docs/reference/cli.md +637 -159
- package/skills/void/docs/reference/config.md +80 -25
- package/skills/void/docs/reference/resource-inference.md +13 -9
- package/skills/void/docs/reference/structure.md +10 -11
- package/AGENT_PROMPT.md +0 -19
- package/dist/cf-access-Bqw81xAf.mjs +0 -22
- package/dist/env-CZy5MorI.mjs +0 -299
- package/dist/env-helpers-z4stu8uc.d.mts +0 -52
- package/dist/env-mask-Dd47NbR6.mjs +0 -90
- package/dist/env-public-BfiLcMBk.d.mts +0 -140
- package/dist/link-CdGHSIy-.mjs +0 -45
- package/dist/mcp-DoM3_nhd.mjs +0 -377
- package/dist/project-paths-GpziKeQQ.d.mts +0 -25
- package/dist/providers-BNKRacMr.d.mts +0 -7
- package/dist/proxy-D-3_D-Gl.mjs +0 -5
- package/dist/rollback-CkvTFXx5.mjs +0 -90
- package/dist/runtime/isr-cache.d.mts +0 -207
- package/dist/runtime/isr-cache.mjs +0 -523
- package/dist/types-lLjNE9Qp.d.mts +0 -51
- package/getting-started-prompt.txt +0 -28
- package/skills/void/command/void.md +0 -7
- package/skills/void/docs/integrations/auth-providers.md +0 -0
- package/skills/void/docs/integrations/payment-processors.md +0 -0
- package/skills/void/docs/node_modules/@iconify/vue/README.md +0 -408
- package/skills/void/docs/node_modules/@iconify/vue/offline/readme.md +0 -5
- package/skills/void/docs/node_modules/@voidzero-dev/vitepress-theme/README.md +0 -103
- package/skills/void/docs/node_modules/oxc-minify/README.md +0 -78
- package/skills/void/docs/node_modules/reka-ui/README.md +0 -80
- package/skills/void/docs/node_modules/vitepress/README.md +0 -28
- package/skills/void/docs/node_modules/vitepress/template/api-examples.md +0 -49
- package/skills/void/docs/node_modules/vitepress/template/index.md +0 -28
- package/skills/void/docs/node_modules/vitepress/template/markdown-examples.md +0 -85
- package/skills/void/docs/node_modules/vitepress-plugin-group-icons/README.md +0 -101
- package/skills/void/docs/node_modules/void/AGENT_PROMPT.md +0 -19
- package/skills/void/docs/node_modules/void/CLAUDE.md +0 -219
- package/skills/void/docs/node_modules/void/README.md +0 -90
- package/skills/void/docs/node_modules/void/node_modules/@clack/prompts/CHANGELOG.md +0 -685
- package/skills/void/docs/node_modules/void/node_modules/@clack/prompts/README.md +0 -396
- package/skills/void/docs/node_modules/void/node_modules/@cloudflare/sandbox/README.md +0 -219
- package/skills/void/docs/node_modules/void/node_modules/@cloudflare/vite-plugin/README.md +0 -37
- package/skills/void/docs/node_modules/void/node_modules/@cloudflare/workers-types/README.md +0 -135
- package/skills/void/docs/node_modules/void/node_modules/@electric-sql/pglite/README.md +0 -189
- package/skills/void/docs/node_modules/void/node_modules/@hono/oauth-providers/CHANGELOG.md +0 -143
- package/skills/void/docs/node_modules/void/node_modules/@hono/oauth-providers/README.md +0 -1272
- package/skills/void/docs/node_modules/void/node_modules/@napi-rs/keyring/README.md +0 -19
- package/skills/void/docs/node_modules/void/node_modules/@types/better-sqlite3/README.md +0 -15
- package/skills/void/docs/node_modules/void/node_modules/@types/node/README.md +0 -15
- package/skills/void/docs/node_modules/void/node_modules/@types/pg/README.md +0 -15
- package/skills/void/docs/node_modules/void/node_modules/@types/proper-lockfile/README.md +0 -51
- package/skills/void/docs/node_modules/void/node_modules/@typescript/native-preview/README.md +0 -22
- package/skills/void/docs/node_modules/void/node_modules/@typescript/native-preview/vendor/vscode-jsonrpc/README.md +0 -69
- package/skills/void/docs/node_modules/void/node_modules/@void/md/README.md +0 -153
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/@shikijs/engine-javascript/README.md +0 -9
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/@shikijs/transformers/README.md +0 -9
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/@types/node/README.md +0 -15
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/gray-matter/CHANGELOG.md +0 -24
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/gray-matter/README.md +0 -565
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/markdown-exit/README.md +0 -127
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/markdown-it-anchor/README.md +0 -600
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/markdown-it-attrs/README.md +0 -386
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/markdown-it-container/README.md +0 -95
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/markdown-it-emoji/README.md +0 -101
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/markdown-it-footnote/README.md +0 -135
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/pathslash/README.md +0 -64
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/shiki/README.md +0 -15
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/tinyglobby/README.md +0 -25
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/AGENTS.md +0 -16
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/README.md +0 -220
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/config/build.md +0 -21
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/config/check.md +0 -35
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/config/create.md +0 -70
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/config/fmt.md +0 -20
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/config/index.md +0 -35
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/config/lint.md +0 -26
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/config/pack.md +0 -17
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/config/run.md +0 -364
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/config/staged.md +0 -15
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/config/test.md +0 -18
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/automatic-data-tracking.md +0 -145
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/build.md +0 -40
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/cache.md +0 -107
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/check.md +0 -60
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/ci.md +0 -62
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/commit-hooks.md +0 -60
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/create.md +0 -341
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/dev.md +0 -24
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/docker.md +0 -175
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/env.md +0 -167
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/fmt.md +0 -41
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/github-actions-cache.md +0 -165
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/ide-integration.md +0 -101
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/implode.md +0 -23
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/index.md +0 -134
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/install.md +0 -199
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/lint.md +0 -50
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/migrate-rules.md +0 -347
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/migrate.md +0 -197
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/monorepo.md +0 -176
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/pack.md +0 -69
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/run.md +0 -356
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/test.md +0 -35
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/troubleshooting.md +0 -108
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/upgrade.md +0 -101
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/vpx.md +0 -66
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/guide/why.md +0 -39
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/index.md +0 -12
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/docs/team.md +0 -35
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/templates/generator/README.md +0 -35
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vite-plus/templates/monorepo/README.md +0 -29
- package/skills/void/docs/node_modules/void/node_modules/@void/md/node_modules/vue/README.md +0 -58
- package/skills/void/docs/node_modules/void/node_modules/arktype/README.md +0 -165
- package/skills/void/docs/node_modules/void/node_modules/better-auth/LICENSE.md +0 -20
- package/skills/void/docs/node_modules/void/node_modules/better-auth/README.md +0 -32
- package/skills/void/docs/node_modules/void/node_modules/better-sqlite3/README.md +0 -99
- package/skills/void/docs/node_modules/void/node_modules/blake3-jit/README.md +0 -108
- package/skills/void/docs/node_modules/void/node_modules/drizzle-arktype/README.md +0 -51
- package/skills/void/docs/node_modules/void/node_modules/drizzle-kit/README.md +0 -79
- package/skills/void/docs/node_modules/void/node_modules/drizzle-orm/README.md +0 -44
- package/skills/void/docs/node_modules/void/node_modules/drizzle-valibot/README.md +0 -51
- package/skills/void/docs/node_modules/void/node_modules/drizzle-zod/README.md +0 -65
- package/skills/void/docs/node_modules/void/node_modules/es-module-lexer/README.md +0 -403
- package/skills/void/docs/node_modules/void/node_modules/estree-walker/README.md +0 -48
- package/skills/void/docs/node_modules/void/node_modules/hono/README.md +0 -85
- package/skills/void/docs/node_modules/void/node_modules/ignore/README.md +0 -452
- package/skills/void/docs/node_modules/void/node_modules/jsonc-parser/CHANGELOG.md +0 -76
- package/skills/void/docs/node_modules/void/node_modules/jsonc-parser/LICENSE.md +0 -21
- package/skills/void/docs/node_modules/void/node_modules/jsonc-parser/README.md +0 -364
- package/skills/void/docs/node_modules/void/node_modules/jsonc-parser/SECURITY.md +0 -41
- package/skills/void/docs/node_modules/void/node_modules/magic-string/README.md +0 -325
- package/skills/void/docs/node_modules/void/node_modules/ofetch/README.md +0 -398
- package/skills/void/docs/node_modules/void/node_modules/pathslash/README.md +0 -64
- package/skills/void/docs/node_modules/void/node_modules/pg/README.md +0 -96
- package/skills/void/docs/node_modules/void/node_modules/pglite-server/README.md +0 -135
- package/skills/void/docs/node_modules/void/node_modules/picocolors/README.md +0 -21
- package/skills/void/docs/node_modules/void/node_modules/proper-lockfile/CHANGELOG.md +0 -108
- package/skills/void/docs/node_modules/void/node_modules/proper-lockfile/README.md +0 -183
- package/skills/void/docs/node_modules/void/node_modules/tinyglobby/README.md +0 -25
- package/skills/void/docs/node_modules/void/node_modules/valibot/LICENSE.md +0 -9
- package/skills/void/docs/node_modules/void/node_modules/valibot/README.md +0 -94
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/AGENTS.md +0 -16
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/README.md +0 -220
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/config/build.md +0 -21
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/config/check.md +0 -35
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/config/create.md +0 -70
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/config/fmt.md +0 -20
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/config/index.md +0 -35
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/config/lint.md +0 -26
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/config/pack.md +0 -17
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/config/run.md +0 -364
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/config/staged.md +0 -15
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/config/test.md +0 -18
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/automatic-data-tracking.md +0 -145
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/build.md +0 -40
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/cache.md +0 -107
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/check.md +0 -60
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/ci.md +0 -62
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/commit-hooks.md +0 -60
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/create.md +0 -341
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/dev.md +0 -24
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/docker.md +0 -175
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/env.md +0 -167
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/fmt.md +0 -41
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/github-actions-cache.md +0 -165
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/ide-integration.md +0 -101
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/implode.md +0 -23
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/index.md +0 -134
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/install.md +0 -199
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/lint.md +0 -50
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/migrate-rules.md +0 -347
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/migrate.md +0 -197
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/monorepo.md +0 -176
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/pack.md +0 -69
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/run.md +0 -356
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/test.md +0 -35
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/troubleshooting.md +0 -108
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/upgrade.md +0 -101
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/vpx.md +0 -66
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/guide/why.md +0 -39
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/index.md +0 -12
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/docs/team.md +0 -35
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/templates/generator/README.md +0 -35
- package/skills/void/docs/node_modules/void/node_modules/vite-plus/templates/monorepo/README.md +0 -29
- package/skills/void/docs/node_modules/void/node_modules/wrangler/README.md +0 -63
- package/skills/void/docs/node_modules/void/node_modules/zod/README.md +0 -191
- package/skills/void/docs/node_modules/void/skills/migrate-vite-cloudflare-to-void/SKILL.md +0 -175
- package/skills/void/docs/node_modules/void/skills/void/SKILL.md +0 -76
- package/skills/void/docs/node_modules/void/skills/void/command/void.md +0 -7
- package/skills/void/docs/node_modules/void/test/e2e/README.md +0 -85
|
@@ -0,0 +1,609 @@
|
|
|
1
|
+
---
|
|
2
|
+
outline: deep
|
|
3
|
+
---
|
|
4
|
+
|
|
5
|
+
# Email
|
|
6
|
+
|
|
7
|
+
Send transactional email from your app via [Cloudflare's `send_email` binding](https://developers.cloudflare.com/email-routing/email-workers/send-email-workers/). Import `sendEmail` from `void/email` and Void handles MIME construction, binding inference, and a local-dev inbox.
|
|
8
|
+
|
|
9
|
+
```ts
|
|
10
|
+
import { sendEmail } from 'void/email';
|
|
11
|
+
|
|
12
|
+
const result = await sendEmail({
|
|
13
|
+
from: 'Acme <acme+noreply@mail.void.cloud>',
|
|
14
|
+
to: 'user@example.com',
|
|
15
|
+
subject: 'Welcome',
|
|
16
|
+
text: 'Thanks for signing up!',
|
|
17
|
+
// html: '<p>Thanks for signing up!</p>', // optional — sent as multipart/alternative when paired with text
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
if (!result.ok) {
|
|
21
|
+
if ('error' in result) {
|
|
22
|
+
// Nothing was sent — the whole call failed before delivery.
|
|
23
|
+
console.error(result.error.code, result.error.message);
|
|
24
|
+
} else {
|
|
25
|
+
// Some recipients failed. `deliveries` says which.
|
|
26
|
+
for (const d of result.deliveries.filter((d) => !d.ok)) {
|
|
27
|
+
console.error(d.recipient, d.error.code, d.error.message);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
Both branches are real. `result.error` exists only on the first, so reading it
|
|
34
|
+
unconditionally throws on the second — and the second is the one a new project
|
|
35
|
+
hits first, because a recipient you have not verified yet fails per-recipient.
|
|
36
|
+
|
|
37
|
+
## Setup
|
|
38
|
+
|
|
39
|
+
Zero config on the Void platform (`void deploy`). Every Void project ships with:
|
|
40
|
+
|
|
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.
|
|
42
|
+
- **No worker binding to add** — outbound mail is sent by the Void proxy, which holds the
|
|
43
|
+
platform `send_email` binding. Your worker never gets one, so there is nothing to configure.
|
|
44
|
+
- **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`.
|
|
45
|
+
|
|
46
|
+
That's it for configuration. Delivery is gated separately: outbound mail only reaches verified recipients, so `sendEmail({ to: '...', subject: '...', text: '...' })` works on first deploy for your own address once it is verified, and for anyone else after `void email allow` — see [Adding recipients](#adding-recipients).
|
|
47
|
+
|
|
48
|
+
Deploying to your own Cloudflare account instead (`void deploy --platform cloudflare`) takes one line of `void.json` and one Enter on the first deploy — see [Your own Cloudflare account](#your-own-cloudflare-account).
|
|
49
|
+
|
|
50
|
+
## Adding recipients
|
|
51
|
+
|
|
52
|
+
Cloudflare's `send_email` binding only delivers to addresses you've registered as recipients. Add them with the CLI:
|
|
53
|
+
|
|
54
|
+
```sh
|
|
55
|
+
void email allow user@acme.com
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
Cloudflare emails the recipient with a verification link. Once they click it and `void email destinations` has picked the click up, you can send to that address from your project. If the link did not arrive or has expired, run `void email allow <address>` again while the address is still pending — the CLI re-sends the link, or tells you how to get a fresh one. List the project's recipients:
|
|
59
|
+
|
|
60
|
+
```sh
|
|
61
|
+
void email destinations
|
|
62
|
+
```
|
|
63
|
+
|
|
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.
|
|
65
|
+
|
|
66
|
+
::: warning When this is the right fit
|
|
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
|
+
|
|
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.
|
|
70
|
+
:::
|
|
71
|
+
|
|
72
|
+
## Options
|
|
73
|
+
|
|
74
|
+
| Option | Type | Notes |
|
|
75
|
+
| ------------- | ---------------------------- | ---------------------------------------------------------------- |
|
|
76
|
+
| `from` | `string \| { email, name? }` | Optional. Pinned to the project sender — see below. |
|
|
77
|
+
| `to` | `Address \| Address[]` | Required. One or more recipients. |
|
|
78
|
+
| `subject` | `string` | Required. UTF-8 supported (encoded as RFC 2047). |
|
|
79
|
+
| `text` | `string` | At least one of `text` / `html` is required. |
|
|
80
|
+
| `html` | `string` | Sent as `multipart/alternative` if both are provided. |
|
|
81
|
+
| `replyTo` | `Address` | Optional `Reply-To` header. |
|
|
82
|
+
| `cc`, `bcc` | `Address \| Address[]` | Optional. Each recipient is sent its own message envelope. |
|
|
83
|
+
| `headers` | `Record<string, string>` | Custom headers; reserved headers (From, Date, etc.) are ignored. |
|
|
84
|
+
| `attachments` | `Attachment[]` | See [Attachments](#attachments). |
|
|
85
|
+
|
|
86
|
+
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.
|
|
87
|
+
|
|
88
|
+
`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
|
+
|
|
90
|
+
On the platform the sender is pinned to your project. `from` must be your project's own platform address — `<project-slug>@mail.void.cloud` or `<project-slug>+<tag>@mail.void.cloud`, optionally with a display name (`Acme <acme+noreply@mail.void.cloud>`). Anything else is rejected with `INVALID_FROM`. Omit `from` and Void fills in `<project-slug>+noreply@mail.void.cloud` for you. Sending from your own domain on the platform is not supported yet.
|
|
91
|
+
|
|
92
|
+
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
|
+
|
|
94
|
+
## Attachments
|
|
95
|
+
|
|
96
|
+
```ts
|
|
97
|
+
await sendEmail({
|
|
98
|
+
from: 'Acme <acme+noreply@mail.void.cloud>',
|
|
99
|
+
to: 'user@example.com',
|
|
100
|
+
subject: 'Your receipt',
|
|
101
|
+
text: 'Receipt attached.',
|
|
102
|
+
attachments: [
|
|
103
|
+
{
|
|
104
|
+
filename: 'receipt.pdf',
|
|
105
|
+
content: pdfBytes, // string | Uint8Array | ArrayBuffer | Blob
|
|
106
|
+
contentType: 'application/pdf',
|
|
107
|
+
},
|
|
108
|
+
],
|
|
109
|
+
});
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
For inline images (e.g. logos referenced from HTML), set `disposition: 'inline'` and `contentId`:
|
|
113
|
+
|
|
114
|
+
```ts
|
|
115
|
+
await sendEmail({
|
|
116
|
+
from: 'Acme <acme+noreply@mail.void.cloud>',
|
|
117
|
+
to: 'user@example.com',
|
|
118
|
+
subject: 'Hello',
|
|
119
|
+
html: '<img src="cid:logo" alt="Acme">',
|
|
120
|
+
attachments: [
|
|
121
|
+
{
|
|
122
|
+
filename: 'logo.png',
|
|
123
|
+
content: logoBytes,
|
|
124
|
+
contentType: 'image/png',
|
|
125
|
+
contentId: 'logo',
|
|
126
|
+
disposition: 'inline',
|
|
127
|
+
},
|
|
128
|
+
],
|
|
129
|
+
});
|
|
130
|
+
```
|
|
131
|
+
|
|
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 (after base64 expansion) is capped at 10 MB to match Cloudflare's limit; oversize payloads return `MIME_ERROR` instead of failing upstream.
|
|
133
|
+
|
|
134
|
+
## Result and errors
|
|
135
|
+
|
|
136
|
+
`sendEmail` returns a discriminated union — there are no thrown errors:
|
|
137
|
+
|
|
138
|
+
```ts
|
|
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
|
+
```
|
|
153
|
+
|
|
154
|
+
`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.
|
|
155
|
+
|
|
156
|
+
There are three discriminated cases:
|
|
157
|
+
|
|
158
|
+
- **All success** (`ok: true`) — every recipient delivered.
|
|
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`.
|
|
161
|
+
|
|
162
|
+
```ts
|
|
163
|
+
const result = await sendEmail({ to: ['a@x.dev', 'b@x.dev', 'c@x.dev'], ... });
|
|
164
|
+
if (result.ok) {
|
|
165
|
+
// every recipient delivered
|
|
166
|
+
} else if ('error' in result) {
|
|
167
|
+
// pre-flight failure — retry the whole call
|
|
168
|
+
} else {
|
|
169
|
+
// mid-batch failure — retry only the failed recipients
|
|
170
|
+
const toRetry = result.deliveries.filter((d) => !d.ok).map((d) => d.recipient);
|
|
171
|
+
}
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
Error codes:
|
|
175
|
+
|
|
176
|
+
| Code | Meaning |
|
|
177
|
+
| ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
|
178
|
+
| `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. |
|
|
179
|
+
| `INVALID_FROM` | `from` was missing, malformed, not the project sender (platform), or a sender Cloudflare would not accept (own account). |
|
|
180
|
+
| `INVALID_TO` | `to` was empty, contained an invalid or over-long address, or the call had more than 100 recipients. |
|
|
181
|
+
| `UNVERIFIED_DESTINATION` | Cloudflare rejected the recipient as unverified. |
|
|
182
|
+
| `MIME_ERROR` | Failed to build the MIME message (oversize attachments, malformed input). |
|
|
183
|
+
| `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. |
|
|
184
|
+
| `UPSTREAM_ERROR` | Other binding failure; the original error is attached as `error.cause`. |
|
|
185
|
+
|
|
186
|
+
Which branch carries the code depends on when the send failed.
|
|
187
|
+
`BINDING_MISSING`, `INVALID_FROM`, `INVALID_TO` and `MIME_ERROR` are pre-flight,
|
|
188
|
+
so they arrive as a top-level `result.error`. `UNVERIFIED_DESTINATION` is always
|
|
189
|
+
per-recipient and therefore only ever appears inside `result.deliveries` —
|
|
190
|
+
never as `result.error`. `QUOTA_EXCEEDED` and `UPSTREAM_ERROR` can arrive either
|
|
191
|
+
way. Narrow with `'error' in result` rather than assuming.
|
|
192
|
+
|
|
193
|
+
## Local development
|
|
194
|
+
|
|
195
|
+
During `void dev`, sends are captured to an in-memory inbox instead of going to Cloudflare. The dev server prints the inbox URL when it starts:
|
|
196
|
+
|
|
197
|
+
```
|
|
198
|
+
[void] Email inbox: http://localhost:5173/__void/inbox?token=<printed-token>
|
|
199
|
+
```
|
|
200
|
+
|
|
201
|
+
The inbox shows a list view with subject, sender, recipient, and timestamp. Click a message to see headers, attachments, and an HTML preview rendered in a sandboxed iframe. Each message can be downloaded as a `.eml` file.
|
|
202
|
+
|
|
203
|
+
Every inbox route requires a local access token. Void includes it in the printed inbox URL and browser links. For command-line requests, send it in the `x-void-dev-trigger` header as shown below; requests without it return `401`. The token is stored in `.void/dev-trigger-token`. Treat inbox URLs as credentials, especially when exposing your dev server to a network.
|
|
204
|
+
|
|
205
|
+
```bash
|
|
206
|
+
# download a message as .eml
|
|
207
|
+
curl http://localhost:5173/__void/inbox/<id>/raw \
|
|
208
|
+
-H "x-void-dev-trigger: <printed-token>" -o message.eml
|
|
209
|
+
|
|
210
|
+
# clear the inbox
|
|
211
|
+
curl -X DELETE http://localhost:5173/__void/inbox \
|
|
212
|
+
-H "x-void-dev-trigger: <printed-token>"
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
The buffer holds the most recent 100 messages and survives HMR but not a full server restart. No disk persistence.
|
|
216
|
+
|
|
217
|
+
Under the hood, your code runs in workerd (a separate process from the Vite dev server), so `sendEmail` hands each captured message to the dev server over the worker's `assets` binding, which Vite wires back into its own middleware. Void apps always have that binding, and so do apps built on a Class A framework (TanStack Start, React Router) once their wrangler config declares one.
|
|
218
|
+
|
|
219
|
+
The dev inbox is **not** available under a Class B or C framework — SvelteKit, Nuxt, Analog, Astro. Those adapters own their own dev server and worker build, so Void never installs the Cloudflare Vite plugin for them and has nowhere to register the inbox. Declaring an `assets` binding does not help: SvelteKit, Nuxt and Analog do not run your code in a workerd instance fronted by Vite at all, so there is no loopback to bind to. Sends from those apps return `BINDING_MISSING`. Use `createEmailTestHarness` from `void/email/testing` instead, which captures in-process and works everywhere. When the inbox is configured but unreachable, `sendEmail` returns `UPSTREAM_ERROR` — nothing is captured and nothing is sent.
|
|
220
|
+
|
|
221
|
+
`sendInDev: true` bypasses the dev inbox for a single send. `void dev` binds no send transport at all — the platform's `__VOID_PROXY` service binding is added only on a deployed worker, and the own-account `SEND_EMAIL` binding is stripped under `serve` so miniflare cannot write stray `.eml` files or send real mail (only that entry: a `send_email` binding of your own under another name is left exactly as `wrangler.jsonc` declares it). With the inbox skipped there is nothing left to fall through to, so the call returns `BINDING_MISSING`: it proves the inbox was bypassed, it does not deliver. To verify real delivery, deploy and send from the deployed worker.
|
|
222
|
+
|
|
223
|
+
```ts
|
|
224
|
+
const result = await sendEmail({
|
|
225
|
+
from: 'acme+noreply@mail.void.cloud',
|
|
226
|
+
to: 'verified@acme.dev',
|
|
227
|
+
subject: 'Skips the dev inbox',
|
|
228
|
+
text: 'Not captured — and not delivered either.',
|
|
229
|
+
sendInDev: true,
|
|
230
|
+
});
|
|
231
|
+
// Under `void dev`: result.error.code === 'BINDING_MISSING'
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
## Testing
|
|
235
|
+
|
|
236
|
+
Use the test harness to assert on outgoing messages without mocking the binding:
|
|
237
|
+
|
|
238
|
+
```ts
|
|
239
|
+
import { describe, it, expect } from 'vitest';
|
|
240
|
+
import { sendEmail } from 'void/email';
|
|
241
|
+
import { createEmailTestHarness } from 'void/email/testing';
|
|
242
|
+
|
|
243
|
+
describe('signup flow', () => {
|
|
244
|
+
it('sends a welcome email', async () => {
|
|
245
|
+
const inbox = createEmailTestHarness();
|
|
246
|
+
|
|
247
|
+
await sendEmail({
|
|
248
|
+
from: 'acme+noreply@mail.void.cloud',
|
|
249
|
+
to: 'user@example.com',
|
|
250
|
+
subject: 'Welcome',
|
|
251
|
+
text: 'Hi!',
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
expect(inbox.messages).toHaveLength(1);
|
|
255
|
+
expect(inbox.messages[0].subject).toBe('Welcome');
|
|
256
|
+
inbox.dispose();
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
The harness intercepts every `sendEmail` call until `dispose()` is called. `clear()` empties the captured list without releasing the sink.
|
|
262
|
+
|
|
263
|
+
## Inbound
|
|
264
|
+
|
|
265
|
+
Inbound mail is a **Void-app-only** feature. Receive it by dropping handlers in the top-level `email/` directory — alongside `crons/` and `queues/`. Void detects them, generates the worker's `email()` export, and dispatches incoming messages by recipient address.
|
|
266
|
+
|
|
267
|
+
Under a third-party framework — TanStack Start, React Router, SvelteKit, Nuxt, Analog, Astro — Void wires only crons and queues into the framework's own worker entry, so there is nowhere to attach an `email()` export. An `email/` directory in those projects is a build error, not silently dead code. Outbound is unaffected: `sendEmail` is a binding, and it works in every framework.
|
|
268
|
+
|
|
269
|
+
The same holds for `target: "node" | "bun" | "deno"`: those builds expose HTTP only and never invoke an `email()` export, so an `email/` directory fails the build with guidance.
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
// email/_default.ts — fallback for any unmatched recipient
|
|
273
|
+
import { defineEmail, parseEmail } from 'void/email';
|
|
274
|
+
|
|
275
|
+
export default defineEmail(async (message, env, ctx) => {
|
|
276
|
+
const parsed = await parseEmail(message);
|
|
277
|
+
|
|
278
|
+
if (parsed.subject?.startsWith('STOP')) {
|
|
279
|
+
message.setReject('Use the unsubscribe link.');
|
|
280
|
+
return;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
await message.forward('archive@example.com');
|
|
284
|
+
});
|
|
285
|
+
```
|
|
286
|
+
|
|
287
|
+
The handler receives Cloudflare's native `ForwardableEmailMessage` plus an optional fourth `info` arg with `params` populated for dynamic / tagged route segments:
|
|
288
|
+
|
|
289
|
+
| Method | Purpose |
|
|
290
|
+
| ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
291
|
+
| `message.setReject(reason)` | Reject the message — the sender sees the SMTP error. The reason is cut to 1000 characters and control characters become spaces; an empty one reads `rejected by handler`. |
|
|
292
|
+
| `message.forward(to, hdrs?)` | Forward to a verified destination address. `to` is a bare address; one `sendEmail` would refuse throws `INVALID_TO` at the call. `hdrs` holds at most 32 headers, each name and value at most 1024 characters; more throws `MIME_ERROR` at the call. |
|
|
293
|
+
| `replyEmail(message, opts)` | Reply with a threaded `In-Reply-To` / `References`. |
|
|
294
|
+
| `message.reply({ from, to, raw })` | Bring your own MIME bytes (`Uint8Array` or stream). `from` and `to` are bare addresses, checked at the call like `forward`. A native `EmailMessage` cannot be replayed — its body has no public accessor. |
|
|
295
|
+
| Returning without action | Accept the message silently. |
|
|
296
|
+
|
|
297
|
+
`parseEmail(message)` wraps [`postal-mime`](https://www.npmjs.com/package/postal-mime) and returns a structured object with `from`, `to`, `subject`, `text`, `html`, `attachments`, and full `headers`. Call it at most once per message — the underlying stream can only be read once.
|
|
298
|
+
|
|
299
|
+
### Per-recipient routing
|
|
300
|
+
|
|
301
|
+
```
|
|
302
|
+
email/
|
|
303
|
+
support.ts — handles support@<your-domain>
|
|
304
|
+
billing.ts — handles billing@<your-domain>
|
|
305
|
+
support+[ticket].ts — handles support+ticket-123@... → info.params.ticket = "ticket-123"
|
|
306
|
+
support+vip.ts — handles exactly support+vip@..., ahead of support+[ticket].ts
|
|
307
|
+
[user].ts — dynamic local-part → info.params.user
|
|
308
|
+
[user]+[tag].ts — dynamic + subaddress → info.params.user, info.params.tag
|
|
309
|
+
_default.ts — fallback when no per-address pattern matches
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
Files are matched against the local-part of the `To` address (the portion before `@`), case-insensitive. **Match precedence (most specific wins):**
|
|
313
|
+
|
|
314
|
+
1. `support+vip.ts` (static + literal tag)
|
|
315
|
+
2. `support+[ticket].ts` (static + captured tag)
|
|
316
|
+
3. `support.ts` (static)
|
|
317
|
+
4. `[user]+vip.ts` (dynamic + literal tag)
|
|
318
|
+
5. `[user]+[tag].ts` (dynamic + captured tag)
|
|
319
|
+
6. `[user].ts` (dynamic)
|
|
320
|
+
7. `_default.ts` (fallback)
|
|
321
|
+
|
|
322
|
+
The first pattern that matches wins. Within a shape, a literal tag beats a captured tag — `support+vip@` reaches `support+vip.ts`, and every other `support+…@` reaches `support+[ticket].ts`. Handlers of the same shape are tried alphabetically.
|
|
323
|
+
|
|
324
|
+
If nothing matches and there's no `_default.ts`, control returns to Cloudflare and the sender receives a non-delivery report. Nested folders under `email/` are ignored — the recipient is a flat string, not a path.
|
|
325
|
+
|
|
326
|
+
### Threaded replies
|
|
327
|
+
|
|
328
|
+
```ts
|
|
329
|
+
// email/support+[ticket].ts
|
|
330
|
+
import { defineEmail, parseEmail, replyEmail } from 'void/email';
|
|
331
|
+
|
|
332
|
+
export default defineEmail(async (message, env, ctx, info) => {
|
|
333
|
+
const parsed = await parseEmail(message);
|
|
334
|
+
await ticketStore.append(info!.params.ticket, parsed.text ?? '');
|
|
335
|
+
|
|
336
|
+
await replyEmail(message, {
|
|
337
|
+
text: `Got your message on ticket ${info!.params.ticket}.`,
|
|
338
|
+
});
|
|
339
|
+
});
|
|
340
|
+
```
|
|
341
|
+
|
|
342
|
+
`replyEmail` builds the reply MIME with `Subject: Re: <original>` (no double-prefix), `In-Reply-To: <Message-ID>`, and a continued `References` chain, then dispatches through the message's `reply()`. Defaults: `to = message.from`, `subject = "Re: <original>"`.
|
|
343
|
+
|
|
344
|
+
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
|
+
|
|
346
|
+
On the platform the `from` default is **not** `message.to`. A reply is sent from
|
|
347
|
+
your project's own slug on the platform mail zone — `<slug>+noreply@<domain>` —
|
|
348
|
+
built from the `__VOID_PROJECT_SLUG` and `__VOID_EMAIL_DOMAIN` bindings the
|
|
349
|
+
platform injects. `replyEmail` throws when the slug is set but the domain is
|
|
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.
|
|
353
|
+
|
|
354
|
+
::: warning The platform pins the reply sender
|
|
355
|
+
You may override `from`, but only with an address on your own slug. The proxy
|
|
356
|
+
accepts a reply sender whose local-part before the first `+` equals the slug the
|
|
357
|
+
message was delivered to, on the zone it arrived on. Anything else is **dropped
|
|
358
|
+
silently** — `replyEmail` still resolves, and the only trace is in the platform's
|
|
359
|
+
log stream, not your project's.
|
|
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.
|
|
364
|
+
:::
|
|
365
|
+
|
|
366
|
+
::: warning The platform gates the reply recipient
|
|
367
|
+
A reply's `to` goes through the same gate as `sendEmail()` and `forward()`: it
|
|
368
|
+
must be one of your project's verified destinations —
|
|
369
|
+
`void email allow <address>`, then the recipient's verification click. A reply
|
|
370
|
+
to any other address is **dropped silently**: the inbound message is still
|
|
371
|
+
accepted, `replyEmail` still resolves, nothing reaches your worker, and the
|
|
372
|
+
only trace is in the platform's log stream, not your project's.
|
|
373
|
+
|
|
374
|
+
So the ticket example above answers only senders you have allowlisted. An
|
|
375
|
+
auto-responder to arbitrary senders needs
|
|
376
|
+
[your own Cloudflare account](#your-own-cloudflare-account)
|
|
377
|
+
(`--platform cloudflare`), where `reply()` is Cloudflare's own reply-to-sender
|
|
378
|
+
and Void adds no allowlist.
|
|
379
|
+
:::
|
|
380
|
+
|
|
381
|
+
Note the asymmetry with `sendEmail`: `sendEmail` returns an error result and
|
|
382
|
+
never throws, while `replyEmail` throws when it cannot determine a sender.
|
|
383
|
+
|
|
384
|
+
### Configuring inbound delivery
|
|
385
|
+
|
|
386
|
+
In production, inbound runs on one shared mail facility. The platform routes `<slug>+anything@<mail domain>` to your worker's `email()` export — **there is nothing to configure in the Cloudflare dashboard and no per-project DNS work**. Your handlers are live as soon as the deploy lands.
|
|
387
|
+
|
|
388
|
+
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
|
+
|
|
390
|
+
There is no local inbound trigger yet — `void dev` serves the outbound dev inbox only, so test inbound handlers with `createInboundTestHarness` below.
|
|
391
|
+
|
|
392
|
+
### Testing inbound handlers
|
|
393
|
+
|
|
394
|
+
Use `createInboundTestHarness` to run handlers through the real dispatcher with a synthesized message and capture replies / forwards / rejects.
|
|
395
|
+
|
|
396
|
+
`deliver({ to })` takes the address **as it arrives on the wire**, which is
|
|
397
|
+
`<slug>+<tag>@<domain>` — the same form the platform delivers to your worker.
|
|
398
|
+
The harness strips the `<slug>+` prefix before matching, exactly as the
|
|
399
|
+
generated dispatcher does on the platform, so `acme+support+abc-123@acme.dev`
|
|
400
|
+
is what matches `email/support+[ticket].ts`. Pass the user-facing
|
|
401
|
+
`support+abc-123@acme.dev` and the harness reads `support` as the slug, leaving
|
|
402
|
+
`abc-123` to match — which falls through to `_default`. A worker on your own
|
|
403
|
+
Cloudflare account receives no slug and matches the full local part; pass
|
|
404
|
+
`slug: null` to test that lane, and `support@mail.acme.com` reaches
|
|
405
|
+
`email/support.ts` while `support+abc-123@mail.acme.com` reaches
|
|
406
|
+
`email/support+[ticket].ts` with `abc-123`:
|
|
407
|
+
|
|
408
|
+
```ts
|
|
409
|
+
const harness = createInboundTestHarness({
|
|
410
|
+
slug: null,
|
|
411
|
+
routes: { support, 'support+[ticket]': ticket },
|
|
412
|
+
});
|
|
413
|
+
await harness.deliver({ from: 'a@x.dev', to: 'support+abc-123@mail.acme.com' });
|
|
414
|
+
```
|
|
415
|
+
|
|
416
|
+
```ts
|
|
417
|
+
import { describe, it, expect } from 'vitest';
|
|
418
|
+
import { createInboundTestHarness } from 'void/email/testing';
|
|
419
|
+
import support from './email/support+[ticket]';
|
|
420
|
+
import defaultHandler from './email/_default';
|
|
421
|
+
|
|
422
|
+
describe('email handlers', () => {
|
|
423
|
+
it('routes to support+[ticket] and extracts the tag', async () => {
|
|
424
|
+
const harness = createInboundTestHarness({
|
|
425
|
+
routes: { 'support+[ticket]': support, _default: defaultHandler },
|
|
426
|
+
});
|
|
427
|
+
const result = await harness.deliver({
|
|
428
|
+
from: 'user@example.com',
|
|
429
|
+
to: 'acme+support+abc-123@acme.dev',
|
|
430
|
+
subject: 'Help',
|
|
431
|
+
text: 'I have a problem',
|
|
432
|
+
});
|
|
433
|
+
expect(result.handler).toBe('support+[ticket]');
|
|
434
|
+
expect(result.params).toEqual({ ticket: 'abc-123' });
|
|
435
|
+
});
|
|
436
|
+
|
|
437
|
+
it('rejects STOP messages via _default', async () => {
|
|
438
|
+
const harness = createInboundTestHarness({ routes: { _default: defaultHandler } });
|
|
439
|
+
await harness.deliver({
|
|
440
|
+
from: 'user@example.com',
|
|
441
|
+
to: 'acme+unknown@acme.dev',
|
|
442
|
+
subject: 'STOP',
|
|
443
|
+
text: 'bye',
|
|
444
|
+
});
|
|
445
|
+
expect(harness.rejects).toEqual(['Use the unsubscribe link.']);
|
|
446
|
+
});
|
|
447
|
+
});
|
|
448
|
+
```
|
|
449
|
+
|
|
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 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.
|
|
451
|
+
|
|
452
|
+
## Your own Cloudflare account
|
|
453
|
+
|
|
454
|
+
`void deploy --platform cloudflare` sets email up on a zone **you** own, through your Cloudflare sign-in (`void cloudflare login`). You never handle a Cloudflare object — no zone id, no routing rule, no token scope. You type one address, read one checklist, and press Enter once.
|
|
455
|
+
|
|
456
|
+
### Two things to type, once
|
|
457
|
+
|
|
458
|
+
1. **`email.from` in `void.json`** — the default sender, and the domain Void sets up:
|
|
459
|
+
|
|
460
|
+
```json
|
|
461
|
+
{
|
|
462
|
+
"email": {
|
|
463
|
+
"from": "Acme <support@mail.acme.com>"
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
```
|
|
467
|
+
|
|
468
|
+
The host (`mail.acme.com`) must be a zone in the Cloudflare account your deploy is pinned to, or sit under one. Without `email.from`, a deploy that uses email prints `add "email": { "from": "you@mail.acme.com" } to void.json` and deploys without it. Void does not pick a zone for you — wrangler cannot list them — and it never writes `void.json`.
|
|
469
|
+
|
|
470
|
+
2. **`void cloudflare login`**, or **`CLOUDFLARE_API_TOKEN`** set to a token with **Email Routing Edit** and **Email Sending Edit** (zone and account) alongside the deploy permissions. A browser session created by older Cloudflare tooling lacks the two email scopes; the deploy tells you to run `void cloudflare logout`, then `void cloudflare login` (one browser Allow) and skips the email step. A token's permissions cannot be listed up front, so a token that lacks one shows up as a row Void could not read (`unknown`), with the permission named. A Global API Key pair (`CLOUDFLARE_API_KEY`) is refused: it has no single bearer for the two calls wrangler has no command for.
|
|
471
|
+
|
|
472
|
+
### The first deploy
|
|
473
|
+
|
|
474
|
+
Before any of your project code runs, the deploy **reads** — the session, the zone, public DNS (MX on the mail domain and on the apex, `_dmarc`, `cf-bounce`), Email Routing status, the zone's subaddressing setting, the routing rules on your addresses, Email Sending, and what `wrangler.jsonc` already holds — and prints what it found and what it would change:
|
|
475
|
+
|
|
476
|
+
```
|
|
477
|
+
Email in use email/support+[ticket].ts · email/_default.ts · sendEmail() in 2 files
|
|
478
|
+
domain mail.acme.com (void.json email.from)
|
|
479
|
+
zone acme.com account Acme (f721b8e5…) · session dev@acme.com
|
|
480
|
+
apex MX aspmx.l.google.com left alone — mail lives on the subdomain
|
|
481
|
+
routing mail.acme.com not enabled · subaddressing off
|
|
482
|
+
sending mail.acme.com not onboarded
|
|
483
|
+
rules support@mail.acme.com → acme-support (absent)
|
|
484
|
+
binding SEND_EMAIL (absent)
|
|
485
|
+
sender noreply@mail.acme.com (vars.__VOID_EMAIL_FROM absent)
|
|
486
|
+
|
|
487
|
+
This changes YOUR Cloudflare account. acme.com itself is not touched.
|
|
488
|
+
+ enable Email Routing on mail.acme.com Cloudflare writes and locks 3 MX + 1 SPF record there
|
|
489
|
+
+ turn on subaddressing for acme.com support+anything@ reaches support@
|
|
490
|
+
+ onboard mail.acme.com for Email Sending MX/SPF/DKIM on cf-bounce.mail.acme.com, _dmarc.mail.acme.com (p=reject)
|
|
491
|
+
+ wrangler.jsonc send_email: [{ name: "SEND_EMAIL" }], addresses: ["support@mail.acme.com"], vars.__VOID_EMAIL_FROM: "noreply@mail.acme.com"
|
|
492
|
+
+ routing rule (created on deploy) support@mail.acme.com → acme-support
|
|
493
|
+
! email/_default.ts a catch-all exists only on an apex; other @mail.acme.com mail bounces
|
|
494
|
+
|
|
495
|
+
◆ Set up email on mail.acme.com? ● Yes / ○ No
|
|
496
|
+
```
|
|
497
|
+
|
|
498
|
+
Enter (Yes is the default) applies only the `+` rows that are not ready, then the deploy continues as usual: the build, the version upload and activation, wrangler's trigger synchronization — which creates the routing rules from the `addresses` array wrangler now finds in your config and prints its own `Email Routing plan:` — and finally the address map:
|
|
499
|
+
|
|
500
|
+
```
|
|
501
|
+
✔ deployed acme-support
|
|
502
|
+
inbound support@mail.acme.com → email/support+[ticket].ts
|
|
503
|
+
outbound sendEmail() from support@mail.acme.com
|
|
504
|
+
```
|
|
505
|
+
|
|
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.
|
|
507
|
+
|
|
508
|
+
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
|
+
|
|
510
|
+
A subdomain is added to a zone that already routes. When Email Routing is **off** on the apex (`Enabled: false`), the subdomain step is not attempted at all — Void never enables routing on the apex from the subdomain path, since that would lock MX records over the apex's live mail — and the checklist prints the dashboard step (`Email → Settings → Subdomains → add mail.acme.com`) instead; `addresses` is withheld until routing on the subdomain reads ready.
|
|
511
|
+
|
|
512
|
+
### Subdomain or apex
|
|
513
|
+
|
|
514
|
+
Put mail on a **subdomain** (`mail.acme.com`) unless you have a reason not to. Cloudflare writes its MX and SPF records there and locks them; `acme.com` itself is not touched, so mail you already receive on the apex keeps flowing. The one rule Void enforces: **it never enables routing over live mail.** If the mail domain already has MX records that are not Cloudflare's, the email step stops:
|
|
515
|
+
|
|
516
|
+
```
|
|
517
|
+
acme.com already receives mail (aspmx.l.google.com). Void never enables routing over live mail. Use something@mail.acme.com.
|
|
518
|
+
```
|
|
519
|
+
|
|
520
|
+
The apex path (`email.from` on `acme.com` itself) is allowed with the same one Enter when the apex receives no mail yet (no MX records, or only Cloudflare's own). It is the only path with a catch-all — see the derivation table below.
|
|
521
|
+
|
|
522
|
+
**Subaddressing** is a per-zone Cloudflare setting, and it is off by default. Until it is on, a rule for `support@mail.acme.com` does not match `support+T-42@mail.acme.com`. The checklist's `turn on subaddressing for acme.com` row flips it for the whole zone — on the apex and every subdomain — so `email/support+[ticket].ts` works the way it does on the platform.
|
|
523
|
+
|
|
524
|
+
### What Void writes into `wrangler.jsonc`
|
|
525
|
+
|
|
526
|
+
```jsonc
|
|
527
|
+
{
|
|
528
|
+
"send_email": [{ "name": "SEND_EMAIL" }],
|
|
529
|
+
"vars": { "__VOID_EMAIL_FROM": "Acme <support@mail.acme.com>" },
|
|
530
|
+
"addresses": ["support@mail.acme.com"],
|
|
531
|
+
}
|
|
532
|
+
```
|
|
533
|
+
|
|
534
|
+
- **`send_email`** — the binding `sendEmail()` delivers through, one `EmailMessage` per recipient. It is written once routing is enabled or sending is onboarded, never on a bare account. An existing `send_email` entry under another name is refused with the rename — a second binding would be silent.
|
|
535
|
+
- **`__VOID_EMAIL_FROM`** — your `email.from`, so the worker knows its default sender.
|
|
536
|
+
- **`addresses`** — derived from your `email/` directory. `wrangler deploy` turns each entry into an Email Routing rule pointing at this worker (a literal address → this worker; `*@acme.com` → the zone's catch-all). Rules and the catch-all are wrangler's job; Void only derives the list.
|
|
537
|
+
|
|
538
|
+
**Void owns the `addresses` array.** It is rewritten on every deploy from the current `email/` scan while every existing entry is still one Void derives, and deleted — never emptied to `[]`, which would remove every rule wrangler owns — when a later preflight finds routing not ready, with the reason printed (on every branch, including CI and a declined prompt; a stale array left in place would make wrangler's plan fail after the upload, on every deploy). Two consequences:
|
|
539
|
+
|
|
540
|
+
- An address already routed to another worker or to a forwarding rule is **pruned** from the array and reported (`! support@mail.acme.com already routed to …; left alone, not in addresses`). wrangler's plan is never destructive on your account because of something Void derived.
|
|
541
|
+
- If the array holds entries Void did not derive, the deploy prints which ones and **skips the email step for that deploy** — the deploy itself continues and the array is left untouched. Remove them, or manage `addresses` by hand and leave `email.from` unset.
|
|
542
|
+
|
|
543
|
+
Deleting a handler leaves its address in `addresses`: the next deploy names it in a `✘` row as an entry Void no longer derives, and skips the email step until you remove that entry from `wrangler.jsonc` by hand (the row says which). Once removed, wrangler's plan drops the rule with its own y/n (default No) in a terminal, an error in CI. Removing the last handler and every `sendEmail()` call leaves the whole setup in `wrangler.jsonc`; the deploy warns which addresses are still routed to a worker with no `email()` export and how to detach them, and deploys as-is.
|
|
544
|
+
|
|
545
|
+
How handlers become addresses, with `email.from` on `mail.acme.com` under the zone `acme.com`:
|
|
546
|
+
|
|
547
|
+
| Handler | Address on a subdomain | Address on the apex |
|
|
548
|
+
| --------------------------------------------------------------- | ---------------------------------------------------- | ---------------------------- |
|
|
549
|
+
| `email/support.ts`, `email/support+[ticket].ts` | `support@mail.acme.com` | `support@acme.com` |
|
|
550
|
+
| `email/_default.ts` | **none** — a catch-all exists only on an apex | `*@acme.com` (the catch-all) |
|
|
551
|
+
| `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
|
+
|
|
553
|
+
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.
|
|
554
|
+
|
|
555
|
+
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
|
+
|
|
557
|
+
### Sending
|
|
558
|
+
|
|
559
|
+
`sendEmail()` uses the worker's own `SEND_EMAIL` binding; there is no proxy hop and no platform quota — Cloudflare's own limits apply, reported as `QUOTA_EXCEEDED`. The `void email usage`, `logs`, `destinations`, `allow` and `disallow` commands are platform commands and do not apply here.
|
|
560
|
+
|
|
561
|
+
Setup is per mail domain, not per direction: any use of email — an `email/` handler or a `void/email` import — sets the domain up for routing and sending together, so an app that only receives is still onboarded for Email Sending and still gets the binding; Cloudflare meters outbound per message, so a domain that never sends costs nothing, and `replyEmail` goes through Email Routing's own `message.reply()`, which needs no onboarding either way.
|
|
562
|
+
|
|
563
|
+
Who you can send to depends on your Workers plan. Onboarding the mail domain for Email Sending is what allows **arbitrary recipients**, and it needs **Workers Paid** — billing is dashboard-only, so Void cannot do that for you. On Workers Free the onboarding row fails and the deploy prints:
|
|
564
|
+
|
|
565
|
+
```
|
|
566
|
+
Workers Paid needed for arbitrary recipients. Inbound works; sendEmail() to verified destinations only.
|
|
567
|
+
```
|
|
568
|
+
|
|
569
|
+
Everything else — routing, rules, the binding — still goes through, and the address map ends with `outbound sendEmail() from support@mail.acme.com (verified destinations only)`. Verified destinations are the addresses under **Email Routing → Destination addresses** in your Cloudflare dashboard; a handler that `forward()`s to a new address needs the same verification click.
|
|
570
|
+
|
|
571
|
+
The refusal is remembered by the `send_email` binding that same run writes into `wrangler.jsonc`: Void writes the binding only after it has attempted the onboarding, so a committed binding next to a domain that is still not onboarded means "tried, refused". Later deploys ask nothing about it, `--require-email` passes, `void email status` reads the domain as set up (its sending row says `not onboarded — verified destinations only` and names the retry), and the address map keeps ending with `(verified destinations only)`. The deploy never retries the onboarding on its own. After upgrading to Workers Paid, run `void email setup --platform cloudflare` once: it asks `Onboard mail.acme.com for Email Sending?` and, on Yes, onboards the domain — from then on the map ends without the marker.
|
|
572
|
+
|
|
573
|
+
If `_dmarc.mail.acme.com` or `cf-bounce.mail.acme.com` already has a TXT record, sending onboarding is refused — it writes its own `_dmarc` (`p=reject`) and DKIM records and Cloudflare would answer with a conflict. Inbound is unaffected; remove the records or keep sending off.
|
|
574
|
+
|
|
575
|
+
### CI
|
|
576
|
+
|
|
577
|
+
The email prompt follows wrangler's own interactivity rule: a CI environment as wrangler detects it, or stdin or stdout not a terminal, means non-interactive. A non-interactive deploy that finds something not ready prints the checklist and
|
|
578
|
+
|
|
579
|
+
```
|
|
580
|
+
deploy: email on mail.acme.com is not set up, and this shell cannot ask.
|
|
581
|
+
Run `void email setup --platform cloudflare` once locally, commit wrangler.jsonc, then redeploy — deploying without email.
|
|
582
|
+
```
|
|
583
|
+
|
|
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`.
|
|
585
|
+
|
|
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`. 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)).
|
|
587
|
+
|
|
588
|
+
### If the subdomain step is refused
|
|
589
|
+
|
|
590
|
+
Enabling routing on a subdomain and switching subaddressing on have no wrangler command, and neither does reading the subaddressing flag or telling a zone in another account from one not on Cloudflare. For those calls Void borrows your session's bearer through `wrangler auth token --json`, uses it inside one function, and drops it — nothing is stored, refreshed or written to disk (the child runs with `WRANGLER_WRITE_LOGS=false`, because wrangler would otherwise log the token). Every other read and write goes through Void's own pinned wrangler.
|
|
591
|
+
|
|
592
|
+
When either call is refused, or the token cannot be read, the deploy does not fail. It prints what Cloudflare answered and the one-time dashboard step:
|
|
593
|
+
|
|
594
|
+
```
|
|
595
|
+
✘ routing mail.acme.com: Cloudflare answered 403 …
|
|
596
|
+
Cloudflare dashboard → your zone → Email → Settings → Subdomains → add the subdomain, then redeploy
|
|
597
|
+
```
|
|
598
|
+
|
|
599
|
+
and **withholds `addresses` for that run** — no routing rule is created, so nothing points at a worker on a subdomain that does not yet receive mail. Do the dashboard step once and redeploy; the routing row then reads ready and `addresses` is written.
|
|
600
|
+
|
|
601
|
+
### What stays manual
|
|
602
|
+
|
|
603
|
+
1. One Enter on the first deploy — it changes your account and locks DNS records.
|
|
604
|
+
2. `void cloudflare login` once — or a `CLOUDFLARE_API_TOKEN` with Email Routing Edit + Email Sending Edit (what a CI runner needs).
|
|
605
|
+
3. `email.from` typed once.
|
|
606
|
+
4. Workers Paid, if you need `sendEmail()` to arbitrary recipients.
|
|
607
|
+
5. A verification click when a handler `forward()`s to a new destination.
|
|
608
|
+
6. The Subdomains form in the dashboard, only if the subdomain step above is refused.
|
|
609
|
+
7. A y/n when a deploy would delete a routing rule — wrangler's own semantics.
|