1---
2title: Send & receive email
3description: Enable Email Routing on a zone, verify destination addresses, forward inbound mail with rules and a catch-all, and send email from a Worker with the send_email binding.
4sidebar:
5 order: 19
6---
7
8Cloudflare Email Routing turns a zone you own into a mail endpoint:
9inbound mail to any address on the domain is matched by rules and
10forwarded, dropped, or handed to a Worker — and once routing is
11enabled, Workers can also *send* mail from that domain through a
12`send_email` binding. This guide wires up both directions. For the
13full Email surface — sending subdomains, Email Security — see
14[Email](/cloudflare/email).
15
16You need a zone on Cloudflare first — see
17[Custom domains & routes](/cloudflare/networking/custom-domains) for
18creating or adopting one. The resource snippets below run inside a
19Stack's `Effect.gen` body:
20
21```typescript
22// alchemy.run.ts
23import * as Alchemy from "alchemy";
24import * as Cloudflare from "alchemy/Cloudflare";
25import * as Effect from "effect/Effect";
26
27export default Alchemy.Stack(
28 "MyApp",
29 { providers: Cloudflare.providers(), state: Cloudflare.state() },
30 Effect.gen(function* () {
31 // resources go here
32 return {};
33 }),
34);
35```
36
37## Enable Email Routing on the zone
38
39`Email.Routing` flips Email Routing on for a zone. It's the
40prerequisite for everything else on this page — receiving mail
41through rules *and* sending mail from a Worker:
42
43```typescript
44const routing = yield* Cloudflare.Email.Routing("Routing", {
45 zone: "example.com",
46});
47// routing.zoneId, routing.enabled, routing.status
48```
49
50`zone` accepts a zone id, a zone name, or a `{ zoneId }` object.
51Moving the resource to a different zone is a replacement; destroying
52it disables Email Routing on the zone.
53
54## Verify a destination address
55
56Rules can only forward to addresses Cloudflare has verified.
57`Email.Address` registers a destination address on the account:
58
59```typescript
60const inbox = yield* Cloudflare.Email.Address("Inbox", {
61 email: "you@personal.example",
62});
63// inbox.verified, inbox.verifiedAt
64```
65
66Cloudflare emails a verification link when the address is first
67created — until the recipient clicks it, rules forwarding there will
68not deliver. The `verified` attribute reflects the current status.
69Destination addresses are account-scoped (not per-zone), and changing
70`email` triggers a replacement.
71
72## Forward an address
73
74`Email.Rule` matches inbound mail on the zone and applies actions —
75`forward` to verified destinations, `drop`, or `worker`:
76
77```typescript
78const rule = yield* Cloudflare.Email.Rule("InboxRule", {
79 zone: "example.com",
80 name: "Forward inbox to destination",
81 matchers: [{ type: "literal", field: "to", value: "inbox@example.com" }],
82 actions: [{ type: "forward", value: ["you@personal.example"] }],
83});
84```
85
86The `literal` matcher is an exact match on the `to:` address. Mail
87that matches no rule falls through to the zone's catch-all rule.
88Lower `priority` numbers run first (default `0`).
89
90## Catch everything else
91
92The catch-all rule is a per-zone singleton — once Email Routing is
93enabled it always exists (disabled and dropping mail by default), so
94`Email.CatchAll` never creates or deletes anything physical. It puts
95the desired configuration, and on destroy restores whatever the rule
96looked like before alchemy managed it:
97
98```typescript
99yield* Cloudflare.Email.CatchAll("CatchAll", {
100 zone: routing.zoneId,
101 actions: [{ type: "forward", value: ["you@personal.example"] }],
102});
103```
104
105To silently discard unmatched mail instead, use a `drop` action:
106
107```typescript
108yield* Cloudflare.Email.CatchAll("DropTheRest", {
109 zone: routing.zoneId,
110 name: "drop unmatched",
111 actions: [{ type: "drop" }],
112});
113```
114
115## Hand mail to a Worker
116
117Both `Rule` and `CatchAll` accept a `worker` action whose value is
118the deployed Worker script name (the `workerName` attribute of a
119deployed Worker). Matched messages are delivered to that Worker's
120`email` handler instead of a mailbox:
121
122```typescript
123yield* Cloudflare.Email.CatchAll("CatchAllWorker", {
124 zone: routing.zoneId,
125 actions: [{ type: "worker", value: ["my-email-worker"] }],
126});
127```
128
129## Declare a send_email binding
130
131Sending is the other direction. `Email.SendEmail` is a binding
132descriptor, not a cloud resource — it names the binding and records
133optional sender/destination restrictions that Cloudflare enforces at
134send time:
135
136```typescript
137// src/email.ts
138import * as Cloudflare from "alchemy/Cloudflare";
139
140export const Email = Cloudflare.Email.SendEmail("Email", {
141 allowedSenderAddresses: ["bot@example.com"],
142 destinationAddress: "you@personal.example",
143});
144```
145
146`destinationAddress` pins the binding to a single verified
147destination; `allowedDestinationAddresses` allows a list instead (the
148two are mutually exclusive). `allowedSenderAddresses` restricts the
149`from:` side. Omit all three to let the Worker send to any verified
150destination on the account.
151
152Under `alchemy dev`, the binding is lowered onto a local email
153simulator: `send()` validates the message — including the
154sender/destination restrictions — and persists it as a `.eml` file
155under `.alchemy/local/email` instead of delivering mail. Opt into the
156live binding with
157[`Alchemy.remote()`](/environments/local-development#running-a-resource-live-in-dev),
158the same aspect that opts resources out of local emulation:
159
160```typescript
161import * as Alchemy from "alchemy";
162
163export const Email = Cloudflare.Email.SendEmail("Email", {
164 allowedSenderAddresses: ["bot@example.com"],
165 destinationAddress: "you@personal.example",
166}).pipe(Alchemy.remote());
167```
168
169:::caution
170With `Alchemy.remote()`, `send()` from your dev loop sends real mail
171from your domain — a made-up recipient address is still a real send
172attempt.
173:::
174
175## Send from the Worker
176
177Bind the descriptor with `Email.Send` in the Worker's Construction phase and
178provide `Email.SendBinding` as the implementation layer. The client's
179`send` method takes the familiar `{ from, to, subject, text }` shape
180(plus `html`, `cc`, `bcc`, `replyTo`, `headers`, and `attachments`):
181
182```typescript
183// src/notifier.ts
184import * as Cloudflare from "alchemy/Cloudflare";
185import * as Effect from "effect/Effect";
186import * as HttpServerResponse from "effect/http/HttpServerResponse";
187import { Email } from "./email.ts";
188
189export default Cloudflare.Worker(
190 "Notifier",
191 { main: import.meta.url },
192 Effect.gen(function* () {
193 const email = yield* Cloudflare.Email.Send(Email);
194
195 return {
196 fetch: Effect.gen(function* () {
197 yield* email.send({
198 from: "bot@example.com",
199 to: "you@personal.example",
200 subject: "Hello from a Worker",
201 text: "Sent via the send_email binding.",
202 });
203 return yield* HttpServerResponse.json({ ok: true });
204 }).pipe(
205 Effect.catchTag("SendEmailError", (error) =>
206 Effect.succeed(
207 HttpServerResponse.text(error.message, { status: 502 }),
208 ),
209 ),
210 ),
211 };
212 }).pipe(Effect.provide(Cloudflare.Email.SendBinding)),
213);
214```
215
216Cloudflare-side rejections (an unverified destination, a sender
217outside the allowed list) surface as a typed `SendEmailError`, kept
218in the error channel until you handle it — here with
219`Effect.catchTag`. The client also exposes `sendRaw` for a pre-built
220`EmailMessage` from `cloudflare:email`, and `raw` for direct access
221to the underlying runtime binding.
222
223## Add the Worker to the Stack
224
225```typescript
226// alchemy.run.ts
227import Notifier from "./src/notifier.ts";
228
229export default Alchemy.Stack(
230 "MyApp",
231 { providers: Cloudflare.providers(), state: Cloudflare.state() },
232 Effect.gen(function* () {
233 // ...routing, address, and rules from above...
234 const notifier = yield* Notifier;
235 return { url: notifier.url };
236 }),
237);
238```
239
240Two constraints to keep in mind: the `from:` address must live on a
241domain with Email Routing enabled (the `Routing` resource above), and
242the `to:` address must be a verified destination on the account (the
243`Address` resource above). Until the destination's verification link
244is clicked, sends fail with a `SendEmailError`.
245
246## Where next
247
248- [Email](/cloudflare/email) — the concept map for the category.
249- [Custom domains & routes](/cloudflare/networking/custom-domains) — get
250 the zone the mail routes through.
251- [Domains](/cloudflare/networking/domains) — zones and DNS records.
252- [Workers](/cloudflare/compute/workers) — the runtime that sends and
253 receives.
254
255Reference:
256
257- [Routing API reference](/providers/cloudflare/email/routing)
258- [Address API reference](/providers/cloudflare/email/address)
259- [Rule API reference](/providers/cloudflare/email/rule)
260- [CatchAll API reference](/providers/cloudflare/email/catchall)
261- [Send API reference](/providers/cloudflare/email/send)