1---
2title: 2.0.0-beta.38 - Email Routing & Actions
3date: 2026-05-13
4category: release
5excerpt: 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`
10Worker binding) and the **`Action`** plan node for
11arbitrary Effects that run during apply — plus a
12behind-the-scenes Neon refactor onto the typed
13`@distilled.cloud/neon` SDK and a fault-tolerance fix in
14the apply transport.
15
16## Cloudflare Email
17
18Four new resources and one binding cover the whole flow:
19turn on Email Routing for a zone, register verified
20destination addresses, forward inbound mail with routing
21rules, and send outbound mail from a Worker.
22
23```typescript
24// alchemy.run.ts — enable Email Routing on a zone you own
25const routing = yield* Cloudflare.Email.Routing("Routing", {
26 zone: "example.com",
27});
28
29// Register a verified destination
30const ops = yield* Cloudflare.Email.Address("Ops", {
31 email: "ops@example.com",
32});
33
34// Forward inbound info@ → ops@
35const 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)
42export const Email = Cloudflare.Email.SendEmail("EmailOps", {
43 destinationAddress: "ops@example.com",
44 allowedSenderAddresses: ["noreply@example.com"],
45});
46```
47
48Bind `SendEmail` on a Worker and the runtime client gives
49you `send` (parsed) and `sendRaw` (RFC 822 bytes) with the
50same Effect error channel as every other binding:
51
52```typescript
53export 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
80An **Action** is a node in the stack DAG that runs an
81arbitrary Effect during apply when its resolved input
82changes — the missing primitive for things like database
83migrations, cache invalidation, deploy announcements, or
84anything else that isn't a cloud resource but needs to run
85in order alongside one.
86
87The shape is intentionally close to `Resource`: define
88once, instantiate in a stack, pass it inputs, get an
89`Output` back that downstream nodes can depend on.
90
91```typescript
92import * as Alchemy from "alchemy";
93import * as Effect from "effect/Effect";
94
95const Sync = Alchemy.Action("Sync",
96 Effect.fn(function* (input: { table: string }) {
97 return { rows: 42 };
98 }),
99);
100
101// In a stack:
102const rows = yield* Sync({ table: bucket.name });
103// ^? Output<{ rows: number }>
104```
105
106Unlike a Resource, an Action has no
107create/update/replace/delete lifecycle — the engine just
108hashes the resolved input and runs the body when it
109drifts. Removing the Action from the stack drops its
110persisted state in the GC pass; the body is never
111re-invoked on delete.
112
113The plan reports actions on their own row:
114
115```
116Plan: 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
126actions the same way it does for resources.
127
128Init-style construction works too, for the common case
129where the body needs Effect Services from the stack's
130layers:
131
132```typescript
133const 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
145The init Effect runs once per process (memoized), so the
146runner is shared across every instance and every re-run.
147
148Actions also participate in the dependency graph in both
149directions — they can take resource Outputs as input *and*
150their own output can be consumed by downstream resources
151or other actions. The scheduler waits on a separate
152"stable" signal so an action body never observes a
153resource's precreate stub; it only sees terminal cloud
154state.
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
161hand-rolled HTTP wrapper — they route through the typed
162[`@distilled.cloud/neon`](https://www.npmjs.com/package/@distilled.cloud/neon)
163SDK. The user-facing API is unchanged; what's gone is the
164`api.ts` shim, the manual JSON shapes, and the per-call
165error mapping.
166
167```diff
168- import * as api from "./api.ts";
169+ import { getProject, updateProject, deleteProject } from "@distilled.cloud/neon/Operations";
170```
171
172404 handling now uses the SDK's tagged error directly:
173
174```typescript
175getProject({ project_id }).pipe(
176 Effect.catchTag("NotFound", () => Effect.succeed(undefined)),
177)
178```
179
180This is mostly an internal cleanup, but it pays off
181immediately: errors carry typed tags instead of opaque
182strings, retries hang off `Schedule` instead of
183hand-written `try/catch`, and the next time the upstream
184OpenAPI spec moves we regenerate one package instead of
185patching one file.
186
187## Fault tolerance in the apply transport
188
189The transport that streams resource lifecycle events
190between the apply worker and the renderer now treats
191transient send/receive errors as retryable instead of
192collapsing the whole run. A flaky connection that drops
193mid-apply no longer aborts the stack — the transport
194backs 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)