void 0.22.0 → 0.23.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.
Files changed (276) hide show
  1. package/README.md +5 -1
  2. package/dist/{account-cmd-CjyxcGkM.mjs → account-cmd-C84Ee8cO.mjs} +4 -4
  3. package/dist/{scan-ClYmX3sa.mjs → application-analysis-BVsVqc11.mjs} +135 -18
  4. package/dist/application-routing-B7QEjqSR.mjs +27 -0
  5. package/dist/{auth-CZuiVsFh.mjs → auth-CCjt0hcq.mjs} +1 -1
  6. package/dist/{auth-DLNN0D3Z.mjs → auth-CSkdO2Bb.mjs} +4 -47
  7. package/dist/{auth-link-BLO4ptzo.mjs → auth-link-CioEg6uY.mjs} +4 -4
  8. package/dist/{auth-router-ZUg9MF_U.mjs → auth-router-BsR981d4.mjs} +4 -4
  9. package/dist/{better-auth-shared-rsBGBvWJ.mjs → better-auth-shared-hy6RPh9W.mjs} +13 -2
  10. package/dist/{build-cmd-BxR5FROK.mjs → build-cmd-LzvNJORH.mjs} +17 -5
  11. package/dist/{cache-D0sWhgKI.mjs → cache-C4MvnrMH.mjs} +2 -2
  12. package/dist/{cancel-deploy-D4NUqFbi.mjs → cancel-deploy-abUxpP2n.mjs} +2 -2
  13. package/dist/{cf-build-output-BJ6yGEIS.mjs → cf-build-output-BPqOT964.mjs} +2 -1
  14. package/dist/cf-build-output-_0HNWysu.mjs +2 -0
  15. package/dist/cli/cli.mjs +80 -455
  16. package/dist/cli/{cf-compat.mjs → cloudflare-operation-process.mjs} +442 -421
  17. package/dist/cli/env-schema-probe.d.mts +2 -1
  18. package/dist/cli/env-schema-probe.mjs +3 -3
  19. package/dist/client-Cu7jWiF1.mjs +2 -0
  20. package/dist/{client-BTZ3XkrB.mjs → client-RV8NVeB8.mjs} +140 -21
  21. package/dist/{cloudflare-auth-DQkqoMYa.mjs → cloudflare-auth-Qdc7tw8F.mjs} +41 -38
  22. package/dist/{cloudflare-cmd-BKlfGHAy.mjs → cloudflare-cmd-BcrVyTmJ.mjs} +5 -5
  23. package/dist/cloudflare-config-Bktvwtpf.mjs +182 -0
  24. package/dist/{cloudflare-connect-Ctmrw579.mjs → cloudflare-connect-B8uPZ4nx.mjs} +3 -3
  25. package/dist/{cloudflare-operations-BT6OWFBk.mjs → cloudflare-operations-AiWashgg.mjs} +1 -1
  26. package/dist/{cloudflare-operations-B9tzgjmf.mjs → cloudflare-operations-fxHb-byx.mjs} +116 -172
  27. package/dist/{preset-UHj9ARyP.mjs → cloudflare-process-B-wekeR6.mjs} +93 -6
  28. package/dist/{config-VavjpDnp.d.mts → config-BMHb8RCj.d.mts} +1 -0
  29. package/dist/config-C_XRIPx2.mjs +89 -0
  30. package/dist/config-entry.d.mts +1 -1
  31. package/dist/{connect-Dg-WkW-C.mjs → connect-Bd4kJd9U.mjs} +5 -5
  32. package/dist/{create-project-DdoqwFLF.mjs → create-project-Boczwj5r.mjs} +1 -1
  33. package/dist/{create-project-l9J7pbtm.mjs → create-project-bMf6ffLZ.mjs} +2 -2
  34. package/dist/{db-gp2sCXyN.mjs → db-8uG64XLl.mjs} +21 -21
  35. package/dist/{delete-TTedbH6B.mjs → delete-NvbiyeJf.mjs} +2 -2
  36. package/dist/{deploy-CH-o2CaZ.mjs → deploy-DzAIwqNU.mjs} +908 -582
  37. package/dist/{deploy-B19L2Bra.mjs → deploy-bjXdFCtn.mjs} +1 -1
  38. package/dist/{domain-JRF59P_r.mjs → domain-y5Tvydvo.mjs} +3 -3
  39. package/dist/{email-Bo6G9LOZ.mjs → email-B3umsW75.mjs} +13 -28
  40. package/dist/{env-BYOrWQnv.mjs → env-BS6qYHDb.mjs} +4 -4
  41. package/dist/{env-public-BxU_0yTL.d.mts → env-public-BX_r8HR6.d.mts} +1 -1
  42. package/dist/{env-validation-BB4GkLxn.mjs → env-validation-BPn7vk-V.mjs} +18 -71
  43. package/dist/{env-validation-BXge7uyK.mjs → env-validation-DbTg7-ar.mjs} +1 -1
  44. package/dist/{fetch-CXDChK7B.mjs → fetch-BIZJh7vR.mjs} +2 -2
  45. package/dist/{fetch-stream-AOByI7Ki.mjs → fetch-stream-IvCYKyQL.mjs} +24 -4
  46. package/dist/{gen-sCtlCOdA.mjs → gen-B580--vC.mjs} +2 -2
  47. package/dist/gen-BVaUUumi.mjs +2 -0
  48. package/dist/{github-cmd-0-7PexDq.mjs → github-cmd-v45BdfKX.mjs} +2 -2
  49. package/dist/{handler-HEcZsaij.d.mts → handler-CZ4nAylQ.d.mts} +1 -1
  50. package/dist/help-DofyZuY7.mjs +2 -0
  51. package/dist/{help-GKtwl07I.mjs → help-daGKjXGk.mjs} +229 -747
  52. package/dist/index.d.mts +1 -1
  53. package/dist/index.mjs +288 -1502
  54. package/dist/info-B97bTX9N.mjs +113 -0
  55. package/dist/{init-FZx3Elvz.mjs → init-Bvy7zrBo.mjs} +46 -15
  56. package/dist/limits-Bq5LG8Id.d.mts +27 -0
  57. package/dist/limits-Cjuk2VPm.mjs +68 -0
  58. package/dist/{link-BrQfb_CU.mjs → link-CDqqCjFl.mjs} +3 -3
  59. package/dist/{list-D44jmIAM.mjs → list-CZj0dzKY.mjs} +3 -3
  60. package/dist/{live-CKJlvlNp.d.mts → live-Chw1eIMv.d.mts} +1 -1
  61. package/dist/{local-d1-Bg9OzEEO.mjs → local-d1-2CMnpuW_.mjs} +2 -2
  62. package/dist/login-DYt_An22.mjs +2 -0
  63. package/dist/{login-CAsAsQ-_.mjs → login-UZKFM_u7.mjs} +3 -3
  64. package/dist/{logs-BjKvFnVM.mjs → logs-CUZ6t9t3.mjs} +3 -3
  65. package/dist/migrate-8_2u55MD.mjs +2 -0
  66. package/dist/{migrate-BPITvDJN.mjs → migrate-DHul7PRV.mjs} +2 -1
  67. package/dist/{node-Dt7z256D.mjs → node-BkyRWRx8.mjs} +1 -1
  68. package/dist/operator-args-CLgKGlwU.mjs +690 -0
  69. package/dist/{operator-cmd-BK5CCiT9.mjs → operator-cmd-DBA6dl0m.mjs} +68 -22
  70. package/dist/pages/client.d.mts +34 -2
  71. package/dist/pages/client.mjs +59 -3
  72. package/dist/pages/index.d.mts +1 -1
  73. package/dist/pages/index.mjs +1 -1
  74. package/dist/pages/islands-plugin.mjs +1 -1
  75. package/dist/pages/protocol.d.mts +2 -2
  76. package/dist/pages/protocol.mjs +2 -308
  77. package/dist/{parse-filename-DioPHiR9.mjs → parse-filename-CUbj-1MP.mjs} +35 -1
  78. package/dist/plan-D1Q5rf-r.mjs +2 -0
  79. package/dist/plan-NPpwZ_kc.mjs +58 -0
  80. package/dist/platform-args-BJdRtlLq.mjs +506 -0
  81. package/dist/platform-args-D3RXyR6h.mjs +2 -0
  82. package/dist/{platform-auth-config-BlN8xTdD.mjs → platform-auth-config-B56E9YP1.mjs} +3 -3
  83. package/dist/{platform-auth-protection-7d5aV2Jg.mjs → platform-auth-protection-BDWO_aER.mjs} +2 -2
  84. package/dist/{platform-auth-recovery-Dxij8ZbR.mjs → platform-auth-recovery-1gg4CSRe.mjs} +3 -3
  85. package/dist/{platform-cmd-E0FuL212.mjs → platform-cmd-Bt-1w6pY.mjs} +1 -1
  86. package/dist/{platform-cmd-B3hwKrFK.mjs → platform-cmd-DMcQStSc.mjs} +24 -3
  87. package/dist/{platform-domain-D6Xcy9ZX.mjs → platform-domain-BNkcz0OB.mjs} +2 -2
  88. package/dist/{platform-lifecycle-k0E0xoxx.mjs → platform-lifecycle-9OyBALhH.mjs} +327 -215
  89. package/dist/{platform-lifecycle-DMh_qrry.mjs → platform-lifecycle-BE6C_jxh.mjs} +1 -1
  90. package/dist/{platform-management-Brz_BDiT.mjs → platform-management-CjwLVQwN.mjs} +6 -3
  91. package/dist/{platform-management-DOtss0BN.mjs → platform-management-CnyTdcWX.mjs} +1 -1
  92. package/dist/platform-plans-config-BNGKGr4P.mjs +359 -0
  93. package/dist/{platform-recovery-BvtcXON5.mjs → platform-recovery-D6KSpuFm.mjs} +2 -2
  94. package/dist/{plugin-inference-BMfKRSqE.mjs → plugin-inference-CXWnn79A.mjs} +174 -64
  95. package/dist/{prepare-C-6YZyyg.mjs → prepare-B5Mkic5u.mjs} +1 -1
  96. package/dist/{prepare-CRhJbVrG.mjs → prepare-DOsL0CC9.mjs} +4 -20
  97. package/dist/prepare-cgvDMtSb.mjs +2 -0
  98. package/dist/{project-cmd-BZLCqOqw.mjs → project-cmd-CNzrqvBv.mjs} +16 -16
  99. package/dist/{project-team-BvgM0WoX.mjs → project-team-DQOWPfQT.mjs} +2 -2
  100. package/dist/{project-token-l5DqK6Az.mjs → project-token-CTDYU0Cc.mjs} +2 -2
  101. package/dist/{project-zero-trust-v6Mvpp8y.mjs → project-zero-trust-BNAkW-_0.mjs} +2 -2
  102. package/dist/{protocol-BBa6cstI.d.mts → protocol-CjF_iI9X.d.mts} +2 -2
  103. package/dist/protocol-U7bfjHmA.mjs +331 -0
  104. package/dist/{provision-Fr9pRqce.mjs → provision-C4ORE7G7.mjs} +1 -1
  105. package/dist/{provision-C4IGqkBf.mjs → provision-CJFgTZY8.mjs} +89 -198
  106. package/dist/{requests-DN-BbNiM.mjs → requests-MhxYnau8.mjs} +2 -2
  107. package/dist/resource-name-C7LVpcRm.mjs +11 -0
  108. package/dist/{rollback-D7rzSaZM.mjs → rollback-CJ6iSDoU.mjs} +3 -3
  109. package/dist/route-url-CG7U-cRN.mjs +15 -0
  110. package/dist/{runner-B8wXwWlo.mjs → runner-CXA9Fh8h.mjs} +1 -1
  111. package/dist/{runner-p-dMs2UN.mjs → runner-Ol0TNjk6.mjs} +2 -2
  112. package/dist/runtime/ai.d.mts +12 -8
  113. package/dist/runtime/ai.mjs +84 -17
  114. package/dist/runtime/better-auth-mysql.mjs +1 -1
  115. package/dist/runtime/better-auth-pg.mjs +1 -1
  116. package/dist/runtime/better-auth.mjs +1 -1
  117. package/dist/runtime/client-react.mjs +2 -2
  118. package/dist/runtime/client-solid.mjs +2 -2
  119. package/dist/runtime/client-svelte.mjs +2 -2
  120. package/dist/runtime/client-vue.mjs +2 -2
  121. package/dist/runtime/client.mjs +2 -2
  122. package/dist/runtime/durable.d.mts +3 -1
  123. package/dist/runtime/durable.mjs +4 -1
  124. package/dist/runtime/email/testing.mjs +1 -1
  125. package/dist/runtime/env-public.d.mts +1 -1
  126. package/dist/runtime/fetch-stream.mjs +1 -1
  127. package/dist/runtime/fetch.mjs +1 -1
  128. package/dist/runtime/handler.d.mts +1 -1
  129. package/dist/runtime/kv.mjs +0 -1
  130. package/dist/runtime/limits.d.mts +2 -0
  131. package/dist/runtime/limits.mjs +2 -0
  132. package/dist/runtime/live-client.d.mts +1 -1
  133. package/dist/runtime/live-server.mjs +25 -16
  134. package/dist/runtime/live.d.mts +1 -1
  135. package/dist/runtime/migration-handler.mjs +62 -42
  136. package/dist/runtime/route-url.d.mts +4 -0
  137. package/dist/runtime/route-url.mjs +2 -0
  138. package/dist/runtime/routing.d.mts +180 -0
  139. package/dist/runtime/routing.mjs +1082 -0
  140. package/dist/runtime/sandbox-container.d.mts +1 -1
  141. package/dist/runtime/sandbox-container.mjs +1 -1
  142. package/dist/runtime/sandbox.d.mts +3 -3
  143. package/dist/runtime/sandbox.mjs +75 -45
  144. package/dist/runtime/sse.mjs +1 -1
  145. package/dist/runtime/validator.d.mts +1 -1
  146. package/dist/runtime/ws-server.d.mts +4 -2
  147. package/dist/runtime/ws-server.mjs +27 -2
  148. package/dist/runtime/ws.d.mts +2 -2
  149. package/dist/runtime/ws.mjs +8 -6
  150. package/dist/{sandbox-qpNBT8a3.d.mts → sandbox-XZAqzFlG.d.mts} +11 -8
  151. package/dist/{sandbox-container-DdNEBfCc.d.mts → sandbox-container-Bo0eiFKz.d.mts} +2 -1
  152. package/dist/{sandbox-container-4fdqLnyb.mjs → sandbox-container-C6ItmVuN.mjs} +4 -2
  153. package/dist/{scan-4tfN-PSn.mjs → scan-C7okrLyM.mjs} +4 -35
  154. package/dist/{secret-BOtOl_cb.mjs → secret-Dli5fP0B.mjs} +4 -4
  155. package/dist/{sse-BaC1jXko.mjs → sse-CQNaDFFV.mjs} +6 -3
  156. package/dist/validate-Dq_L3s0S.mjs +2 -0
  157. package/dist/{validate-CIUwFpjB.mjs → validate-ctOrgiS3.mjs} +2 -1
  158. package/dist/{wrangler-DQF1vKyf.mjs → wrangler-7K-bW_DL.mjs} +8 -239
  159. package/dist/{ws-BoY7vQML.d.mts → ws-CL1w7GXU.d.mts} +13 -2
  160. package/package.json +15 -8
  161. package/skills/migrate-vite-cloudflare-to-void/SKILL.md +34 -157
  162. package/skills/void/SKILL.md +50 -135
  163. package/skills/void/docs/guide/ai.md +94 -84
  164. package/skills/void/docs/guide/app-types.md +3 -32
  165. package/skills/void/docs/guide/auth.md +12 -116
  166. package/skills/void/docs/guide/database/d1.md +9 -54
  167. package/skills/void/docs/guide/database/mysql.md +1 -1
  168. package/skills/void/docs/guide/database/postgresql.md +5 -26
  169. package/skills/void/docs/guide/database.md +23 -75
  170. package/skills/void/docs/guide/deployment.md +27 -113
  171. package/skills/void/docs/guide/durable-state.md +43 -18
  172. package/skills/void/docs/guide/edge/headers.md +3 -47
  173. package/skills/void/docs/guide/edge/prerendering.md +5 -20
  174. package/skills/void/docs/guide/edge/redirects.md +11 -64
  175. package/skills/void/docs/guide/edge/revalidation.md +5 -18
  176. package/skills/void/docs/guide/edge/rewrites.md +56 -284
  177. package/skills/void/docs/guide/edge/static-assets.md +22 -72
  178. package/skills/void/docs/guide/email/domains.md +112 -0
  179. package/skills/void/docs/guide/email/receiving.md +139 -0
  180. package/skills/void/docs/guide/email/sending.md +231 -0
  181. package/skills/void/docs/guide/email.md +13 -619
  182. package/skills/void/docs/guide/env-migration.md +11 -11
  183. package/skills/void/docs/guide/env-vars.md +9 -29
  184. package/skills/void/docs/guide/index.md +0 -15
  185. package/skills/void/docs/guide/jobs.md +3 -18
  186. package/skills/void/docs/guide/kv.md +5 -11
  187. package/skills/void/docs/guide/live.md +5 -56
  188. package/skills/void/docs/guide/pages-routing/actions-and-forms.md +78 -125
  189. package/skills/void/docs/guide/pages-routing/head.md +10 -10
  190. package/skills/void/docs/guide/pages-routing/islands.md +6 -36
  191. package/skills/void/docs/guide/pages-routing/layouts.md +6 -128
  192. package/skills/void/docs/guide/pages-routing/loaders.md +3 -19
  193. package/skills/void/docs/guide/pages-routing/markdown.md +13 -171
  194. package/skills/void/docs/guide/pages-routing/overview.md +7 -17
  195. package/skills/void/docs/guide/pages-routing/view-transitions.md +1 -1
  196. package/skills/void/docs/guide/platform/administration/access.md +1 -4
  197. package/skills/void/docs/guide/platform/administration/email.md +35 -8
  198. package/skills/void/docs/guide/platform/administration/operations.md +26 -4
  199. package/skills/void/docs/guide/platform/administration/plans.md +126 -0
  200. package/skills/void/docs/guide/platform/administration/projects.md +5 -2
  201. package/skills/void/docs/guide/platform/administration/zero-trust.md +23 -152
  202. package/skills/void/docs/guide/platform/development/runtime.md +3 -13
  203. package/skills/void/docs/guide/platform/development/schema-ci.md +0 -58
  204. package/skills/void/docs/guide/platform/installation/credentials.md +6 -4
  205. package/skills/void/docs/guide/platform/installation/domains.md +30 -2
  206. package/skills/void/docs/guide/platform/installation/first-deployment.md +2 -0
  207. package/skills/void/docs/guide/platform/installation/maintenance.md +3 -1
  208. package/skills/void/docs/guide/platform/installation/prerequisites.md +20 -15
  209. package/skills/void/docs/guide/platform/installation/setup.md +9 -5
  210. package/skills/void/docs/guide/platform-administration.md +1 -0
  211. package/skills/void/docs/guide/queues.md +7 -9
  212. package/skills/void/docs/guide/quickstart.md +38 -37
  213. package/skills/void/docs/guide/remote-dev.md +4 -9
  214. package/skills/void/docs/guide/sandboxes.md +29 -21
  215. package/skills/void/docs/guide/server-routing.md +9 -72
  216. package/skills/void/docs/guide/sse.md +4 -18
  217. package/skills/void/docs/guide/ssg.md +3 -15
  218. package/skills/void/docs/guide/ssr.md +14 -62
  219. package/skills/void/docs/guide/storage.md +9 -4
  220. package/skills/void/docs/guide/type-safety.md +3 -14
  221. package/skills/void/docs/guide/typed-fetch.md +3 -7
  222. package/skills/void/docs/guide/websockets.md +68 -40
  223. package/skills/void/docs/integrations/agents.md +3 -3
  224. package/skills/void/docs/integrations/cloudflare.md +85 -316
  225. package/skills/void/docs/integrations/frameworks/analog.md +5 -64
  226. package/skills/void/docs/integrations/frameworks/astro.md +4 -73
  227. package/skills/void/docs/integrations/frameworks/nuxt.md +5 -62
  228. package/skills/void/docs/integrations/frameworks/overview.md +11 -54
  229. package/skills/void/docs/integrations/frameworks/react-router.md +5 -60
  230. package/skills/void/docs/integrations/frameworks/sveltekit.md +6 -65
  231. package/skills/void/docs/integrations/frameworks/tanstack-start.md +4 -62
  232. package/skills/void/docs/integrations/nodejs-bun-deno.md +5 -69
  233. package/skills/void/docs/reference/api/auth.md +156 -0
  234. package/skills/void/docs/reference/api/client.md +87 -0
  235. package/skills/void/docs/reference/api/database.md +95 -0
  236. package/skills/void/docs/reference/api/durable.md +46 -0
  237. package/skills/void/docs/reference/api/env.md +50 -0
  238. package/skills/void/docs/reference/api/handlers.md +254 -0
  239. package/skills/void/docs/reference/api/pages.md +241 -0
  240. package/skills/void/docs/reference/api/plugin.md +39 -0
  241. package/skills/void/docs/reference/api/resources.md +109 -0
  242. package/skills/void/docs/reference/api/rewrites.md +76 -0
  243. package/skills/void/docs/reference/api/types.md +92 -0
  244. package/skills/void/docs/reference/api.md +56 -1218
  245. package/skills/void/docs/reference/cli/auth.md +88 -0
  246. package/skills/void/docs/reference/cli/database.md +128 -0
  247. package/skills/void/docs/reference/cli/deploy.md +85 -0
  248. package/skills/void/docs/reference/cli/domains.md +41 -0
  249. package/skills/void/docs/reference/cli/email.md +129 -0
  250. package/skills/void/docs/reference/cli/generate.md +116 -0
  251. package/skills/void/docs/reference/cli/github.md +189 -0
  252. package/skills/void/docs/reference/cli/platform-config.md +92 -0
  253. package/skills/void/docs/reference/cli/platform-email.md +81 -0
  254. package/skills/void/docs/reference/cli/platform-installation.md +127 -0
  255. package/skills/void/docs/reference/cli/platform-operations.md +90 -0
  256. package/skills/void/docs/reference/cli/platform-users.md +89 -0
  257. package/skills/void/docs/reference/cli/platform-zero-trust.md +45 -0
  258. package/skills/void/docs/reference/cli/platform.md +70 -0
  259. package/skills/void/docs/reference/cli/project.md +214 -0
  260. package/skills/void/docs/reference/cli/secrets.md +76 -0
  261. package/skills/void/docs/reference/cli/setup.md +70 -0
  262. package/skills/void/docs/reference/cli.md +32 -1682
  263. package/skills/void/docs/reference/config.md +12 -18
  264. package/skills/void/docs/reference/resource-inference.md +3 -58
  265. package/skills/void/docs/reference/structure.md +14 -41
  266. package/dist/canonical-json-DuDiiUsQ.mjs +0 -13
  267. package/dist/client-Czz8o5jP.mjs +0 -2
  268. package/dist/gen-CNJ62MM7.mjs +0 -2
  269. package/dist/help-CmZzxUba.mjs +0 -2
  270. package/dist/login-CGcRKEoi.mjs +0 -2
  271. package/dist/migrate-CYfbKkXh.mjs +0 -2
  272. package/dist/plan-BEZ8VJW0.mjs +0 -256
  273. package/dist/plan-DpuOr14e.mjs +0 -2
  274. package/dist/prepare-Bm3iq-u4.mjs +0 -2
  275. package/dist/validate-EKmJWxmy.mjs +0 -2
  276. /package/dist/cli/{cf-compat.d.mts → cloudflare-operation-process.d.mts} +0 -0
@@ -2,627 +2,21 @@
2
2
  outline: deep
3
3
  ---
4
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.example.com>', // use your project's sender address
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
- // A request-level failure; OUTCOME_UNKNOWN may already have been submitted.
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
- Check both failure shapes: `result.error` reports a request failure, while `result.deliveries` reports failures for individual recipients. An unverified recipient fails in `deliveries`.
34
-
35
- ## Setup
36
-
37
- On a platform with email enabled, deploy without additional email configuration. Ask your administrator for its shared mail domain. Administrators [enable email during installation or upgrade](/guide/platform/installation/credentials#runtime-token-permissions); Void Cloud uses `mail.void.cloud`.
38
-
39
- Each project on an email-enabled platform has:
40
-
41
- - **Default sender:** `<your-slug>+noreply@<mail-domain>`. Older projects with slugs longer than 56 characters must pass `from` explicitly.
42
- - **Your account email as a recipient:** it is registered when the project is created. If Cloudflare has not verified it for the platform, follow the emailed verification link and run `void email destinations` before sending to it.
43
-
44
- The shared sender can send only to verified recipients. Verify your own address or [add another recipient](#adding-recipients) before sending.
45
-
46
- Deploying to your own Cloudflare account instead (`void deploy --platform cloudflare`) takes one line of `void.config.ts` and one Enter on the first deploy — see [Your own Cloudflare account](#your-own-cloudflare-account).
47
-
48
- ## Adding recipients
49
-
50
- Cloudflare's `send_email` binding only delivers to addresses you've registered as recipients. Add them with the CLI:
51
-
52
- ```sh
53
- void email allow user@acme.com
54
- ```
55
-
56
- 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:
57
-
58
- ```sh
59
- void email destinations
60
- ```
61
-
62
- The project owner's account email is added automatically when the project is created on an email-enabled platform, so it skips `void email allow` — not the verification. See [Setup](#setup) for when it is verified at once and when there is a link to click.
63
-
64
- ::: warning When this is the right fit
65
- The shared sender suits team alerts, project notifications, and replies to inbound mail.
66
-
67
- For mail to arbitrary users, [register your own domain](#your-own-domain-on-the-platform), use [Workers Paid on your own Cloudflare account](#your-own-cloudflare-account), or call another email provider from your handler.
68
- :::
69
-
70
- ## Your own domain on the platform
71
-
72
- The shared sender uses the platform's configured mail domain. To send — and receive — at a domain you own, register its Cloudflare zone with the project:
73
-
74
- ```sh
75
- void email domain add acme.com
76
- ```
77
-
78
- `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.
79
-
80
- 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.
81
-
82
- 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.
83
-
84
- `void email domain status <domain>` reports three independent results:
85
-
86
- - **Inbound**: whether mail can reach the project's handlers.
87
- - **Outbound**: whether sending is ready, restricted to verified destinations, pending, or blocked.
88
- - **Management**: whether the stored Cloudflare credential can manage the connection.
89
-
90
- 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.
91
-
92
- After the last assignment's ingress is deleted, the platform forgets its stored
93
- credential. Revoke a token you no longer use in Cloudflare; Void does not revoke
94
- tokens you created yourself. Re-adding a removed domain requires a scoped token
95
- again. A domain can move to another project only after its removal completes
96
- and the zone connection's manager authorizes the new assignment.
97
-
98
- 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.
99
-
100
- 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.
101
-
102
- ## Options
103
-
104
- | Option | Type | Notes |
105
- | ---------------- | ---------------------------- | --------------------------------------------------------------------------------------------------------------- |
106
- | `from` | `string \| { email, name? }` | Optional. Pinned to the project sender — see below. |
107
- | `to` | `Address \| Address[]` | Required. One or more recipients. |
108
- | `subject` | `string` | Required. UTF-8 supported (encoded as RFC 2047). |
109
- | `text` | `string` | At least one of `text` / `html` is required. |
110
- | `html` | `string` | Sent as `multipart/alternative` if both are provided. |
111
- | `replyTo` | `Address` | Optional `Reply-To` header. |
112
- | `cc`, `bcc` | `Address \| Address[]` | Optional. Each recipient is sent its own message envelope. |
113
- | `headers` | `Record<string, string>` | Custom headers; reserved headers (From, Date, etc.) are ignored. |
114
- | `attachments` | `Attachment[]` | See [Attachments](#attachments). |
115
- | `idempotencyKey` | `string` | Optional on the Void Platform: 1–128 printable, non-space ASCII characters. Reuse for retries of the same send. |
116
-
117
- You can send to at most 50 recipients across `to`, `cc`, and `bcc`. Addresses must have an ASCII local part of at most 64 characters and a dotted domain; the whole address can be at most 254 characters. Quoted local parts, `user@localhost`, and malformed dots are rejected. Use punycode for internationalized domains. Void returns `INVALID_TO` for an invalid `to`, `INVALID_FROM` for `from`, and `MIME_ERROR` for `cc`, `bcc`, `replyTo`, or invalid custom header names.
118
-
119
- `Address` accepts either a string (`"hello@acme.dev"` or `"Name <hello@acme.dev>"`) or an object (`{ email, name? }`). Display names with non-ASCII characters are RFC 2047 encoded automatically.
120
-
121
- On the platform the sender is pinned to your project. `from` must be your project's own platform address — `<project-slug>@<mail-domain>` or `<project-slug>+<tag>@<mail-domain>`, optionally with a display name — or any address on a domain registered with `void email domain add` (see [Your own domain on the platform](#your-own-domain-on-the-platform)). For example, if your platform's mail domain is `mail.example.com`, you can use `Acme <acme+noreply@mail.example.com>`. Anything else is rejected with `INVALID_FROM`. Omit `from` and Void fills in `<project-slug>+noreply@<mail-domain>` for you.
122
-
123
- On your own Cloudflare account, `from` defaults to `email.from` from `void.config.ts` and must be on a domain your account can send from; Cloudflare rejects any other sender and `sendEmail` reports it as `INVALID_FROM`.
124
-
125
- ## Attachments
126
-
127
- ```ts
128
- await sendEmail({
129
- to: 'user@example.com',
130
- subject: 'Your receipt',
131
- text: 'Receipt attached.',
132
- attachments: [
133
- {
134
- filename: 'receipt.pdf',
135
- content: pdfBytes, // string | Uint8Array | ArrayBuffer | Blob
136
- contentType: 'application/pdf',
137
- },
138
- ],
139
- });
140
- ```
141
-
142
- For inline images (e.g. logos referenced from HTML), set `disposition: 'inline'` and `contentId`:
143
-
144
- ```ts
145
- await sendEmail({
146
- to: 'user@example.com',
147
- subject: 'Hello',
148
- html: '<img src="cid:logo" alt="Acme">',
149
- attachments: [
150
- {
151
- filename: 'logo.png',
152
- content: logoBytes,
153
- contentType: 'image/png',
154
- contentId: 'logo',
155
- disposition: 'inline',
156
- },
157
- ],
158
- });
159
- ```
160
-
161
- Void infers `contentType` from the filename if you omit it. An explicit value must be a valid media type. `contentId` names the image referenced by `cid:<id>` in HTML; use an ID such as `logo` or `logo@acme.dev`. The encoded message is limited to 5 MiB and custom headers to 16 KiB. Invalid attachment fields or oversized messages return `MIME_ERROR`.
162
-
163
- ## Result and errors
164
-
165
- `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.
166
-
167
- `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.
168
-
169
- `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.
170
-
171
- 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.
172
-
173
- Use an idempotency key for sends you may retry:
174
-
175
- ```ts
176
- const result = await sendEmail({
177
- to: 'user@example.com',
178
- subject: 'Invoice ready',
179
- text: 'Your invoice is available in your account.',
180
- idempotencyKey: 'invoice:42:ready',
181
- });
182
-
183
- if (result.ok) {
184
- console.log('Accepted by the provider', result.operationId);
185
- } else {
186
- console.log('Inspect the outcome before retrying', result.operationId, result);
187
- }
188
- ```
189
-
190
- 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.
191
-
192
- | Code | Meaning |
193
- | ------------------------ | -------------------------------------------------------------- |
194
- | `BINDING_MISSING` | No email transport is configured. |
195
- | `INVALID_FROM` | The sender is invalid or not authorized for the project. |
196
- | `INVALID_TO` | The recipient list is invalid or exceeds 50 recipients. |
197
- | `UNVERIFIED_DESTINATION` | The recipient needs verification under the active policy. |
198
- | `MIME_ERROR` | The message is invalid or exceeds a size limit. |
199
- | `QUOTA_EXCEEDED` | A platform or provider quota has been reached. |
200
- | `IDEMPOTENCY_CONFLICT` | The key was already used for another payload. |
201
- | `OUTCOME_UNKNOWN` | The send may have reached the provider; do not blindly resend. |
202
- | `UPSTREAM_ERROR` | The provider or platform refused the request. |
203
-
204
- The default platform allowance is 200 recipient submissions per UTC month and 10 per rolling minute. Reserved and started submissions count toward the limits; a started attempt stays charged even if it fails or its outcome is unknown. Administrators can change these limits.
205
-
206
- `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.
207
-
208
- ## Local development
209
-
210
- 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:
211
-
212
- ```
213
- [void] Email inbox: http://localhost:5173/__void/inbox?token=<printed-token>
214
- ```
215
-
216
- 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.
217
-
218
- 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.
219
-
220
- ```bash
221
- # download a message as .eml
222
- curl http://localhost:5173/__void/inbox/<id>/raw \
223
- -H "x-void-dev-trigger: <printed-token>" -o message.eml
224
-
225
- # clear the inbox
226
- curl -X DELETE http://localhost:5173/__void/inbox \
227
- -H "x-void-dev-trigger: <printed-token>"
228
- ```
229
-
230
- The inbox keeps the most recent 100 messages through HMR and clears on a full server restart.
231
-
232
- The dev inbox works in native Void apps, TanStack Start, and React Router. It is unavailable in SvelteKit, Nuxt, Analog, and Astro; sends from those apps return `BINDING_MISSING`. Use `createEmailTestHarness` from `void/email/testing` to capture sends in tests. If a configured inbox cannot be reached, `sendEmail` returns `UPSTREAM_ERROR` without sending.
233
-
234
- `sendInDev: true` skips the inbox for one call. It returns `BINDING_MISSING` in `void dev` because no live send transport is bound. Deploy the app to test real delivery.
235
-
236
- ```ts
237
- const result = await sendEmail({
238
- from: 'acme+noreply@mail.example.com', // replace with your project sender
239
- to: 'verified@acme.dev',
240
- subject: 'Skips the dev inbox',
241
- text: 'Not captured — and not delivered either.',
242
- sendInDev: true,
243
- });
244
- // Under `void dev`: result.error.code === 'BINDING_MISSING'
245
- ```
246
-
247
- ## Testing
248
-
249
- Use the test harness to assert on outgoing messages without mocking the binding:
250
-
251
- ```ts
252
- import { describe, it, expect } from 'vitest';
253
- import { sendEmail } from 'void/email';
254
- import { createEmailTestHarness } from 'void/email/testing';
255
-
256
- describe('signup flow', () => {
257
- it('sends a welcome email', async () => {
258
- const inbox = createEmailTestHarness();
259
-
260
- await sendEmail({
261
- from: 'acme+noreply@mail.example.com', // use your project sender
262
- to: 'user@example.com',
263
- subject: 'Welcome',
264
- text: 'Hi!',
265
- });
266
-
267
- expect(inbox.messages).toHaveLength(1);
268
- expect(inbox.messages[0].subject).toBe('Welcome');
269
- inbox.dispose();
270
- });
271
- });
272
- ```
273
-
274
- The harness intercepts every `sendEmail` call until `dispose()` is called. `clear()` empties the captured list without releasing the sink.
275
-
276
- ## Inbound
277
-
278
- 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.
279
-
280
- 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.
281
-
282
- 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.
283
-
284
- ```ts
285
- // email/_default.ts — fallback for any unmatched recipient
286
- import { defineEmail, parseEmail } from 'void/email';
287
-
288
- export default defineEmail(async (message, env, ctx) => {
289
- const parsed = await parseEmail(message);
290
-
291
- if (parsed.subject?.startsWith('STOP')) {
292
- message.setReject('Use the unsubscribe link.');
293
- return;
294
- }
295
-
296
- await message.forward('archive@example.com');
297
- });
298
- ```
299
-
300
- The handler receives Cloudflare's native `ForwardableEmailMessage` plus an optional fourth `info` arg with `params` populated for dynamic / tagged route segments:
301
-
302
- | Method | Purpose |
303
- | ---------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
304
- | `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`. |
305
- | `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. |
306
- | `replyEmail(message, opts)` | Reply with a threaded `In-Reply-To` / `References`. |
307
- | `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. |
308
- | Returning without action | Accept the message silently. |
5
+ <script setup>
6
+ import LegacyDocRedirect from '../.vitepress/theme/LegacyDocRedirect.vue';
7
+ import links from '../.vitepress/redirects/guide-email.json';
8
+ </script>
309
9
 
310
- `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.
10
+ <LegacyDocRedirect page="guide/email.md" :links="links" />
311
11
 
312
- ### Per-recipient routing
313
-
314
- ```
315
- email/
316
- support.ts — handles support@<your-domain>
317
- billing.ts — handles billing@<your-domain>
318
- support+[ticket].ts — handles support+ticket-123@... → info.params.ticket = "ticket-123"
319
- support+vip.ts — handles exactly support+vip@..., ahead of support+[ticket].ts
320
- [user].ts — dynamic local-part → info.params.user
321
- [user]+[tag].ts — dynamic + subaddress → info.params.user, info.params.tag
322
- _default.ts — fallback when no per-address pattern matches
323
- ```
324
-
325
- Files are matched against the local-part of the `To` address (the portion before `@`), case-insensitive. **Match precedence (most specific wins):**
326
-
327
- 1. `support+vip.ts` (static + literal tag)
328
- 2. `support+[ticket].ts` (static + captured tag)
329
- 3. `support.ts` (static)
330
- 4. `[user]+vip.ts` (dynamic + literal tag)
331
- 5. `[user]+[tag].ts` (dynamic + captured tag)
332
- 6. `[user].ts` (dynamic)
333
- 7. `_default.ts` (fallback)
334
-
335
- 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.
336
-
337
- 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.
338
-
339
- ### Threaded replies
340
-
341
- ```ts
342
- // email/support+[ticket].ts
343
- import { defineEmail, parseEmail, replyEmail } from 'void/email';
344
-
345
- export default defineEmail(async (message, env, ctx, info) => {
346
- const parsed = await parseEmail(message);
347
- await ticketStore.append(info!.params.ticket, parsed.text ?? '');
348
-
349
- await replyEmail(message, {
350
- text: `Got your message on ticket ${info!.params.ticket}.`,
351
- });
352
- });
353
- ```
354
-
355
- `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>"`.
356
-
357
- Void uses the inbound message's `Message-ID` and `References` when they fit in reply headers. It drops unusable values and falls back to a bare `Re:` for a subject it cannot safely reuse. Pass `subject` to set it explicitly.
358
-
359
- On the platform, shared-domain replies default to
360
- `<slug>+noreply@<mail domain>`. For mail received at a registered custom domain,
361
- replies default to the address that received the message. `replyEmail` throws
362
- when it cannot determine a sender. Native Cloudflare replies default to `message.to`.
363
-
364
- ::: warning The platform pins the reply sender
365
- You may override `from` with your project's shared address or an address on
366
- a registered custom domain your project owns. An unauthorized sender is rejected
367
- and recorded in `void email logs`.
368
-
369
- For shared addressing, `message.to` has the project prefix removed. Use the
370
- default sender instead of copying that stripped address into `from`.
371
- :::
372
-
373
- ::: warning The platform gates the reply recipient
374
- A reply's `to` goes through the platform's recipient policy and the transport's
375
- capability checks. Shared sending defaults to your project's verified
376
- destinations: run `void email allow <address>` and have the recipient complete
377
- verification. A blocked reply is recorded in `void email logs`; the inbound
378
- message remains accepted.
379
-
380
- Custom-domain sends may reach external recipients when Cloudflare Sending is
381
- enabled. Addresses on the platform's own mail domain always require per-project
382
- consent. Native forwarding and reply operations can have additional Cloudflare
383
- restrictions; check the recorded outcome before assuming a reply was accepted.
384
- :::
385
-
386
- In a platform handler, awaiting `replyEmail` or `message.forward` records an
387
- action for execution after the handler finishes. Use `void email logs` to see
388
- its provider outcome. `sendEmail` returns its send result directly.
389
-
390
- `message.forward` requires a native Cloudflare email event. A message relayed
391
- from a customer zone cannot use native forwarding; its forward action is
392
- recorded as `UNSUPPORTED_ACTION` without sending or consuming quota. Replies
393
- remain available through that domain's sending capability.
394
-
395
- ### Configuring inbound delivery
396
-
397
- On an email-enabled platform, `<slug>+anything@<mail domain>` reaches your
398
- deployed `email/` handlers without per-project DNS setup. A
399
- [registered custom domain](#your-own-domain-on-the-platform) reaches those
400
- handlers once its inbound readiness is `ready`. Use `void email domain status`
401
- to check current provider routing and any remaining setup steps.
402
-
403
- On your own Cloudflare account there is no shared facility: the deploy derives one Email Routing rule per handler and records it in `void.lock.json` for you — see [Your own Cloudflare account](#your-own-cloudflare-account).
404
-
405
- There is no local inbound trigger yet — `void dev` serves the outbound dev inbox only, so test inbound handlers with `createInboundTestHarness` below.
406
-
407
- ### Testing inbound handlers
408
-
409
- Use `createInboundTestHarness` to run handlers through the real dispatcher with a synthesized message and capture replies / forwards / rejects.
410
-
411
- `deliver({ to })` takes the address **as it arrives on the wire**, which is
412
- `<slug>+<tag>@<domain>` — the same form the platform delivers to your worker.
413
- The harness strips the `<slug>+` prefix before matching, exactly as the
414
- generated dispatcher does on the platform, so `acme+support+abc-123@acme.dev`
415
- is what matches `email/support+[ticket].ts`. Pass the user-facing
416
- `support+abc-123@acme.dev` and the harness reads `support` as the slug, leaving
417
- `abc-123` to match — which falls through to `_default`. A worker on your own
418
- Cloudflare account receives no slug and matches the full local part; pass
419
- `slug: null` to test that lane, and `support@mail.acme.com` reaches
420
- `email/support.ts` while `support+abc-123@mail.acme.com` reaches
421
- `email/support+[ticket].ts` with `abc-123`:
422
-
423
- ```ts
424
- const harness = createInboundTestHarness({
425
- slug: null,
426
- routes: { support, 'support+[ticket]': ticket },
427
- });
428
- await harness.deliver({ from: 'a@x.dev', to: 'support+abc-123@mail.acme.com' });
429
- ```
430
-
431
- ```ts
432
- import { describe, it, expect } from 'vitest';
433
- import { createInboundTestHarness } from 'void/email/testing';
434
- import support from './email/support+[ticket]';
435
- import defaultHandler from './email/_default';
436
-
437
- describe('email handlers', () => {
438
- it('routes to support+[ticket] and extracts the tag', async () => {
439
- const harness = createInboundTestHarness({
440
- routes: { 'support+[ticket]': support, _default: defaultHandler },
441
- });
442
- const result = await harness.deliver({
443
- from: 'user@example.com',
444
- to: 'acme+support+abc-123@acme.dev',
445
- subject: 'Help',
446
- text: 'I have a problem',
447
- });
448
- expect(result.handler).toBe('support+[ticket]');
449
- expect(result.params).toEqual({ ticket: 'abc-123' });
450
- });
451
-
452
- it('rejects STOP messages via _default', async () => {
453
- const harness = createInboundTestHarness({ routes: { _default: defaultHandler } });
454
- await harness.deliver({
455
- from: 'user@example.com',
456
- to: 'acme+unknown@acme.dev',
457
- subject: 'STOP',
458
- text: 'bye',
459
- });
460
- expect(harness.rejects).toEqual(['Use the unsubscribe link.']);
461
- });
462
- });
463
- ```
464
-
465
- 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.
466
-
467
- ## Your own Cloudflare account
468
-
469
- For direct Cloudflare deployment, set a sender address on a zone you own. Void checks the zone and asks before changing its email settings.
470
-
471
- ### Setup
472
-
473
- 1. Set the default sender and mail domain in `void.config.ts`:
474
-
475
- ```ts
476
- import { defineConfig } from 'void/config';
477
-
478
- export default defineConfig({
479
- email: { from: 'Acme <support@mail.acme.com>' },
480
- });
481
- ```
482
-
483
- The host (`mail.acme.com`) must be a zone in your selected Cloudflare account or a subdomain of one. If you omit `email.from`, Void deploys without email and tells you to set it. It does not choose a zone for you.
484
-
485
- 2. Run `void cloudflare login`, or set `CLOUDFLARE_API_TOKEN` with **Email Routing Edit** and **Email Sending Edit** permissions in addition to deploy permissions. If an older browser session lacks email scopes, log out and sign in again. Void names missing token permissions in its checklist. Global API Keys are not supported for email setup.
486
-
487
- ### The first deploy
488
-
489
- Before building, Void checks the zone, DNS, routing rules, and sending status. It shows a checklist of the changes it would make:
490
-
491
- ```
492
- Email in use email/support+[ticket].ts · email/_default.ts · sendEmail() in 2 files
493
- domain mail.acme.com (void.config.ts email.from)
494
- zone acme.com account Acme (f721b8e5…) · session dev@acme.com
495
- apex MX aspmx.l.google.com left alone — mail lives on the subdomain
496
- routing mail.acme.com not enabled · subaddressing off
497
- sending mail.acme.com not onboarded
498
- rules support@mail.acme.com → acme-support (absent)
499
- binding SEND_EMAIL (absent)
500
- sender noreply@mail.acme.com (vars.__VOID_EMAIL_FROM absent)
501
-
502
- This changes YOUR Cloudflare account. acme.com itself is not touched.
503
- + enable Email Routing on mail.acme.com Cloudflare writes and locks 3 MX + 1 SPF record there
504
- + turn on subaddressing for acme.com support+anything@ reaches support@
505
- + onboard mail.acme.com for Email Sending MX/SPF/DKIM on cf-bounce.mail.acme.com, _dmarc.mail.acme.com (p=reject)
506
- + Cloudflare config send_email: [{ name: "SEND_EMAIL" }], addresses: ["support@mail.acme.com"], vars.__VOID_EMAIL_FROM: "noreply@mail.acme.com"
507
- + routing rule (created on deploy) support@mail.acme.com → acme-support
508
- ! email/_default.ts a catch-all exists only on an apex; other @mail.acme.com mail bounces
509
-
510
- ◆ Set up email on mail.acme.com? ● Yes / ○ No
511
- ```
512
-
513
- Accepting the prompt applies the missing setup, builds and deploys your app, then shows its email address map:
514
-
515
- ```
516
- ✔ deployed acme-support
517
- inbound support@mail.acme.com → email/support+[ticket].ts
518
- outbound sendEmail() from support@mail.acme.com
519
- ```
520
-
521
- Later deploys skip the prompt when setup is ready. Declining before any setup is saved deploys without email.
522
-
523
- 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.
524
-
525
- Void updates the configured addresses when handlers change and keeps the default sender in sync with `email.from`. `void email setup` records the setup; the next deploy creates routing rules and may mark them as `(rule created by this deploy)` in the address map.
526
-
527
- If Email Routing is off on the apex, Void does not enable it while setting up a subdomain; doing so could replace the apex's live mail records. The checklist directs you to **Email → Settings → Subdomains** in the Cloudflare dashboard. Deploy does not add addresses until the subdomain is ready.
528
-
529
- ### Subdomain or apex
530
-
531
- 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:
532
-
533
- ```
534
- acme.com already receives mail (aspmx.l.google.com). Void never enables routing over live mail. Use something@mail.acme.com.
535
- ```
536
-
537
- 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.
538
-
539
- **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.
540
-
541
- ### What Void records in `void.lock.json`
542
-
543
- The relevant fields appear under `resolved`:
544
-
545
- ```jsonc
546
- {
547
- "resolved": {
548
- "send_email": [{ "name": "SEND_EMAIL" }],
549
- "vars": { "__VOID_EMAIL_FROM": "Acme <support@mail.acme.com>" },
550
- "addresses": ["support@mail.acme.com"],
551
- },
552
- }
553
- ```
554
-
555
- - **`send_email`** — the binding used by `sendEmail()`. An existing binding under another name must be renamed before Void can use it.
556
- - **`__VOID_EMAIL_FROM`** — the default sender from `email.from`.
557
- - **`addresses`** — addresses derived from your `email/` handlers. Deploy creates the corresponding Email Routing rules for this Worker.
558
-
559
- Void derives `addresses` from your handlers on each deploy. If routing is not ready, it withholds the array and explains why. It also protects rules it does not own:
560
-
561
- - An address routed to another Worker or a forwarding rule is excluded and reported; Void leaves that rule alone.
562
- - If the array contains addresses Void did not derive, deploy skips email setup and lists them. Remove them, or manage `addresses` yourself and leave `email.from` unset.
563
-
564
- If you delete a handler, the next deploy reports its stale address and skips email setup until you set the desired `cloudflare.addresses` in `void.config.ts`. Removing a routing rule requires confirmation in a terminal and fails in CI without it. If you remove all email use, deploy keeps the existing setup and warns about remaining routes. To detach it, remove `addresses`, `send_email`, and `vars.__VOID_EMAIL_FROM` from `resolved` in `void.lock.json`, remove any authored overrides in `void.config.ts`, then delete the routing rules in Cloudflare.
565
-
566
- How handlers become addresses, with `email.from` on `mail.acme.com` under the zone `acme.com`:
567
-
568
- | Handler | Address on a subdomain | Address on the apex |
569
- | --------------------------------------------------------------- | ---------------------------------------------------- | ---------------------------- |
570
- | `email/support.ts`, `email/support+[ticket].ts` | `support@mail.acme.com` | `support@acme.com` |
571
- | `email/_default.ts` | **none** — a catch-all exists only on an apex | `*@acme.com` (the catch-all) |
572
- | `email/[user].ts`, `email/[user]+[tag].ts` (dynamic local part) | **refused** — a dynamic local part needs a catch-all | `*@acme.com` (the catch-all) |
573
-
574
- 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.
575
-
576
- 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.
577
-
578
- ### Sending
579
-
580
- `sendEmail()` uses the Worker's `SEND_EMAIL` binding. Cloudflare's limits apply, with `QUOTA_EXCEEDED` on failure. The `void email usage`, `logs`, `destinations`, `allow`, and `disallow` commands are for Void platforms.
581
-
582
- Email setup covers both inbound and outbound mail for the domain, even if your app uses only one. Cloudflare meters outbound messages; `replyEmail` uses Email Routing and does not need Email Sending onboarding.
583
-
584
- 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:
585
-
586
- ```
587
- Workers Paid needed for arbitrary recipients. Inbound works; sendEmail() to verified destinations only.
588
- ```
589
-
590
- 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.
591
-
592
- Void remembers when Email Sending onboarding was refused. Later deploys keep inbound mail and sends to verified destinations working; this state also satisfies `--require-email`. Void does not retry onboarding automatically. After upgrading to Workers Paid, run `void email setup --platform cloudflare` to enable arbitrary recipients. `void email status --platform cloudflare` shows whether sending is still limited to verified destinations.
593
-
594
- 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.
595
-
596
- ### CI
597
-
598
- In CI or when input or output is redirected, Void cannot prompt. If email is not ready, it prints the checklist and:
599
-
600
- ```
601
- deploy: email on mail.acme.com is not set up, and this shell cannot ask.
602
- Run `void email setup --platform cloudflare` once locally, commit void.lock.json, then redeploy — deploying without email.
603
- ```
604
-
605
- Void then deploys without email. Pass `--require-email` to fail instead. If setup has recorded the subdomain addresses but its MX records are not visible yet, deploy stops until DNS can be verified. Check progress with `void email status --platform cloudflare`.
606
-
607
- For CI, run `void email setup --platform cloudflare` once locally and commit `void.lock.json`. Then run `void deploy --platform cloudflare --require-email` in CI. After DNS is ready, later deploys need no prompt. Workers Free can still send to verified destinations; see [Sending](#sending).
608
-
609
- ### If the subdomain step is refused
610
-
611
- If Cloudflare refuses subdomain routing or subaddressing, Void prints the response and the dashboard step to complete:
612
-
613
- ```
614
- ✘ routing mail.acme.com: Cloudflare answered 403 …
615
- Cloudflare dashboard → your zone → Email → Settings → Subdomains → add the subdomain, then redeploy
616
- ```
12
+ # Email
617
13
 
618
- Void withholds the addresses until routing is ready. Complete the dashboard step and deploy again.
14
+ Use Void to send transactional email, receive messages in route handlers, and test outgoing mail in a local inbox.
619
15
 
620
- ### What stays manual
16
+ | Guide | Use it for |
17
+ | --------------------------------------- | ---------------------------------------------------------------------------- |
18
+ | [Sending Email](./email/sending.md) | Send messages and attachments, handle errors, and test delivery locally. |
19
+ | [Receiving Email](./email/receiving.md) | Route incoming messages, reply, forward, and test handlers. |
20
+ | [Domains and Setup](./email/domains.md) | Register a domain on your platform or configure your own Cloudflare account. |
621
21
 
622
- 1. One Enter on the first deploy — it changes your account and locks DNS records.
623
- 2. `void cloudflare login` once — or a `CLOUDFLARE_API_TOKEN` with Email Routing Edit + Email Sending Edit (what a CI runner needs).
624
- 3. `email.from` typed once.
625
- 4. Workers Paid, if you need `sendEmail()` to arbitrary recipients.
626
- 5. A verification click when a handler `forward()`s to a new destination.
627
- 6. The Subdomains form in the dashboard, only if the subdomain step above is refused.
628
- 7. Confirmation when a deploy would delete a routing rule.
22
+ On an email-enabled Void platform, start with its [shared sender](./email/sending.md#setup). For a direct Cloudflare deployment, [configure a sender and domain](./email/domains.md#your-own-cloudflare-account) first.