| 1 | --- |
| 2 | title: 2.0.0-beta.38 - Email Routing & Actions |
| 3 | date: 2026-05-13 |
| 4 | category: release |
| 5 | excerpt: Cloudflare Email Routing and the `send_email` Worker binding, a new `Action` plan node for arbitrary Effects that run during apply, a leaner Neon provider on the typed `@distilled.cloud/neon` SDK, and a transport-error retry fix. |
| 6 | --- |
| 7 | |
| 8 | `v2.0.0-beta.38` adds two new building blocks — full |
| 9 | **Cloudflare Email** (zone routing + the `send_email` |
| 10 | Worker binding) and the **`Action`** plan node for |
| 11 | arbitrary Effects that run during apply — plus a |
| 12 | behind-the-scenes Neon refactor onto the typed |
| 13 | `@distilled.cloud/neon` SDK and a fault-tolerance fix in |
| 14 | the apply transport. |
| 15 | |
| 16 | ## Cloudflare Email |
| 17 | |
| 18 | Four new resources and one binding cover the whole flow: |
| 19 | turn on Email Routing for a zone, register verified |
| 20 | destination addresses, forward inbound mail with routing |
| 21 | rules, and send outbound mail from a Worker. |
| 22 | |
| 23 | ```typescript |
| 24 | // alchemy.run.ts — enable Email Routing on a zone you own |
| 25 | const routing = yield* Cloudflare.Email.Routing("Routing", { |
| 26 | zone: "example.com", |
| 27 | }); |
| 28 | |
| 29 | // Register a verified destination |
| 30 | const ops = yield* Cloudflare.Email.Address("Ops", { |
| 31 | email: "ops@example.com", |
| 32 | }); |
| 33 | |
| 34 | // Forward inbound info@ → ops@ |
| 35 | const rule = yield* Cloudflare.Email.Rule("InfoForward", { |
| 36 | zone: "example.com", |
| 37 | matchers: [{ type: "literal", field: "to", value: "info@example.com" }], |
| 38 | actions: [{ type: "forward", value: ["ops@example.com"] }], |
| 39 | }); |
| 40 | |
| 41 | // Declare a `send_email` binding (the id is the env key) |
| 42 | export const Email = Cloudflare.Email.SendEmail("EmailOps", { |
| 43 | destinationAddress: "ops@example.com", |
| 44 | allowedSenderAddresses: ["noreply@example.com"], |
| 45 | }); |
| 46 | ``` |
| 47 | |
| 48 | Bind `SendEmail` on a Worker and the runtime client gives |
| 49 | you `send` (parsed) and `sendRaw` (RFC 822 bytes) with the |
| 50 | same Effect error channel as every other binding: |
| 51 | |
| 52 | ```typescript |
| 53 | export default Cloudflare.Worker("Notifier", |
| 54 | { main: import.meta.filename }, |
| 55 | Effect.gen(function* () { |
| 56 | const email = yield* Cloudflare.SendEmail.bind(Email); |
| 57 | |
| 58 | return { |
| 59 | fetch: Effect.gen(function* () { |
| 60 | yield* email.send({ |
| 61 | from: "noreply@example.com", |
| 62 | to: "ops@example.com", |
| 63 | subject: "Hello", |
| 64 | text: "Hi from the edge", |
| 65 | }); |
| 66 | return HttpServerResponse.text("sent"); |
| 67 | }), |
| 68 | }; |
| 69 | }).pipe(Effect.provide(Cloudflare.SendEmailBindingLive)), |
| 70 | ); |
| 71 | ``` |
| 72 | |
| 73 | [Providers › `EmailRouting`](/providers/cloudflare/email/routing) · |
| 74 | [`EmailAddress`](/providers/cloudflare/email/address) · |
| 75 | [`EmailRule`](/providers/cloudflare/email/rule) · |
| 76 | [`SendEmail`](/providers/cloudflare/email/send) |
| 77 | |
| 78 | ## `Action` — Effects that run as part of apply |
| 79 | |
| 80 | An **Action** is a node in the stack DAG that runs an |
| 81 | arbitrary Effect during apply when its resolved input |
| 82 | changes — the missing primitive for things like database |
| 83 | migrations, cache invalidation, deploy announcements, or |
| 84 | anything else that isn't a cloud resource but needs to run |
| 85 | in order alongside one. |
| 86 | |
| 87 | The shape is intentionally close to `Resource`: define |
| 88 | once, instantiate in a stack, pass it inputs, get an |
| 89 | `Output` back that downstream nodes can depend on. |
| 90 | |
| 91 | ```typescript |
| 92 | import * as Alchemy from "alchemy"; |
| 93 | import * as Effect from "effect/Effect"; |
| 94 | |
| 95 | const Sync = Alchemy.Action("Sync", |
| 96 | Effect.fn(function* (input: { table: string }) { |
| 97 | return { rows: 42 }; |
| 98 | }), |
| 99 | ); |
| 100 | |
| 101 | // In a stack: |
| 102 | const rows = yield* Sync({ table: bucket.name }); |
| 103 | // ^? Output<{ rows: number }> |
| 104 | ``` |
| 105 | |
| 106 | Unlike a Resource, an Action has no |
| 107 | create/update/replace/delete lifecycle — the engine just |
| 108 | hashes the resolved input and runs the body when it |
| 109 | drifts. Removing the Action from the stack drops its |
| 110 | persisted state in the GC pass; the body is never |
| 111 | re-invoked on delete. |
| 112 | |
| 113 | The plan reports actions on their own row: |
| 114 | |
| 115 | ``` |
| 116 | Plan: 1 to create | 1 to update | 1 to run |
| 117 | |
| 118 | ~ Api (Cloudflare.Worker) |
| 119 | + BucketA (Cloudflare.R2.Bucket) |
| 120 | λ AnnounceDeploy (AnnounceDeploy) [action] |
| 121 | · DbMigrate (DbMigrate) [action] |
| 122 | ``` |
| 123 | |
| 124 | `λ` (cyan) means *will run this apply*; `·` (gray) means |
| 125 | *input hash matches, skip*. `--force` flips skip→run for |
| 126 | actions the same way it does for resources. |
| 127 | |
| 128 | Init-style construction works too, for the common case |
| 129 | where the body needs Effect Services from the stack's |
| 130 | layers: |
| 131 | |
| 132 | ```typescript |
| 133 | const Sync = Alchemy.Action("Sync", |
| 134 | Effect.gen(function* () { |
| 135 | const db = yield* Database; |
| 136 | const logger = yield* Logger; |
| 137 | return Effect.fn(function* (input: { table: string }) { |
| 138 | yield* logger.info(`syncing ${input.table}`); |
| 139 | return { rows: yield* db.count(input.table) }; |
| 140 | }); |
| 141 | }), |
| 142 | ); |
| 143 | ``` |
| 144 | |
| 145 | The init Effect runs once per process (memoized), so the |
| 146 | runner is shared across every instance and every re-run. |
| 147 | |
| 148 | Actions also participate in the dependency graph in both |
| 149 | directions — they can take resource Outputs as input *and* |
| 150 | their own output can be consumed by downstream resources |
| 151 | or other actions. The scheduler waits on a separate |
| 152 | "stable" signal so an action body never observes a |
| 153 | resource's precreate stub; it only sees terminal cloud |
| 154 | state. |
| 155 | |
| 156 | [Concepts › Actions](/infrastructure-as-code/action) |
| 157 | |
| 158 | ## Neon on `@distilled.cloud/neon` |
| 159 | |
| 160 | `Neon.Project` and `Neon.Branch` no longer ship a |
| 161 | hand-rolled HTTP wrapper — they route through the typed |
| 162 | [`@distilled.cloud/neon`](https://www.npmjs.com/package/@distilled.cloud/neon) |
| 163 | SDK. The user-facing API is unchanged; what's gone is the |
| 164 | `api.ts` shim, the manual JSON shapes, and the per-call |
| 165 | error mapping. |
| 166 | |
| 167 | ```diff |
| 168 | - import * as api from "./api.ts"; |
| 169 | + import { getProject, updateProject, deleteProject } from "@distilled.cloud/neon/Operations"; |
| 170 | ``` |
| 171 | |
| 172 | 404 handling now uses the SDK's tagged error directly: |
| 173 | |
| 174 | ```typescript |
| 175 | getProject({ project_id }).pipe( |
| 176 | Effect.catchTag("NotFound", () => Effect.succeed(undefined)), |
| 177 | ) |
| 178 | ``` |
| 179 | |
| 180 | This is mostly an internal cleanup, but it pays off |
| 181 | immediately: errors carry typed tags instead of opaque |
| 182 | strings, retries hang off `Schedule` instead of |
| 183 | hand-written `try/catch`, and the next time the upstream |
| 184 | OpenAPI spec moves we regenerate one package instead of |
| 185 | patching one file. |
| 186 | |
| 187 | ## Fault tolerance in the apply transport |
| 188 | |
| 189 | The transport that streams resource lifecycle events |
| 190 | between the apply worker and the renderer now treats |
| 191 | transient send/receive errors as retryable instead of |
| 192 | collapsing the whole run. A flaky connection that drops |
| 193 | mid-apply no longer aborts the stack — the transport |
| 194 | backs off, reconnects, and resumes. |
| 195 | |
| 196 | ## Other changes worth knowing about |
| 197 | |
| 198 | - **`effect@4.0.0-beta.66`.** Tracks the latest Effect |
| 199 | release. No public Alchemy API change; downstream apps |
| 200 | pinning Effect themselves should bump in lockstep. |
| 201 | - **`ignore` is now vendored.** The MIT-licensed |
| 202 | `ignore` package is inlined into the Alchemy build to |
| 203 | shrink the supply-chain surface (one less transitive |
| 204 | dependency for everyone running `alchemy deploy` in CI). |
| 205 | - **OTLP dashboard refresh.** Self-hosted OTel dashboards |
| 206 | now have resource-usage ranking and error-rate charts |
| 207 | per Stack / Stage. No code change in user code — it's |
| 208 | all dashboard config. |
| 209 | |
| 210 | ## Where to go next |
| 211 | |
| 212 | - [CHANGELOG](https://github.com/alchemy-run/alchemy/blob/main/CHANGELOG.md#v200-beta38) |
| 213 | - [Compare v2.0.0-beta.37 → v2.0.0-beta.38](https://github.com/alchemy-run/alchemy/compare/v2.0.0-beta.37...v2.0.0-beta.38) |