| 1 | --- |
| 2 | title: Send & receive email |
| 3 | description: 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. |
| 4 | sidebar: |
| 5 | order: 19 |
| 6 | --- |
| 7 | |
| 8 | Cloudflare Email Routing turns a zone you own into a mail endpoint: |
| 9 | inbound mail to any address on the domain is matched by rules and |
| 10 | forwarded, dropped, or handed to a Worker — and once routing is |
| 11 | enabled, Workers can also *send* mail from that domain through a |
| 12 | `send_email` binding. This guide wires up both directions. For the |
| 13 | full Email surface — sending subdomains, Email Security — see |
| 14 | [Email](/cloudflare/email). |
| 15 | |
| 16 | You need a zone on Cloudflare first — see |
| 17 | [Custom domains & routes](/cloudflare/networking/custom-domains) for |
| 18 | creating or adopting one. The resource snippets below run inside a |
| 19 | Stack's `Effect.gen` body: |
| 20 | |
| 21 | ```typescript |
| 22 | // alchemy.run.ts |
| 23 | import * as Alchemy from "alchemy"; |
| 24 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 25 | import * as Effect from "effect/Effect"; |
| 26 | |
| 27 | export 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 |
| 40 | prerequisite for everything else on this page — receiving mail |
| 41 | through rules *and* sending mail from a Worker: |
| 42 | |
| 43 | ```typescript |
| 44 | const 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. |
| 51 | Moving the resource to a different zone is a replacement; destroying |
| 52 | it disables Email Routing on the zone. |
| 53 | |
| 54 | ## Verify a destination address |
| 55 | |
| 56 | Rules can only forward to addresses Cloudflare has verified. |
| 57 | `Email.Address` registers a destination address on the account: |
| 58 | |
| 59 | ```typescript |
| 60 | const inbox = yield* Cloudflare.Email.Address("Inbox", { |
| 61 | email: "you@personal.example", |
| 62 | }); |
| 63 | // inbox.verified, inbox.verifiedAt |
| 64 | ``` |
| 65 | |
| 66 | Cloudflare emails a verification link when the address is first |
| 67 | created — until the recipient clicks it, rules forwarding there will |
| 68 | not deliver. The `verified` attribute reflects the current status. |
| 69 | Destination 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 |
| 78 | const 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 | |
| 86 | The `literal` matcher is an exact match on the `to:` address. Mail |
| 87 | that matches no rule falls through to the zone's catch-all rule. |
| 88 | Lower `priority` numbers run first (default `0`). |
| 89 | |
| 90 | ## Catch everything else |
| 91 | |
| 92 | The catch-all rule is a per-zone singleton — once Email Routing is |
| 93 | enabled it always exists (disabled and dropping mail by default), so |
| 94 | `Email.CatchAll` never creates or deletes anything physical. It puts |
| 95 | the desired configuration, and on destroy restores whatever the rule |
| 96 | looked like before alchemy managed it: |
| 97 | |
| 98 | ```typescript |
| 99 | yield* Cloudflare.Email.CatchAll("CatchAll", { |
| 100 | zone: routing.zoneId, |
| 101 | actions: [{ type: "forward", value: ["you@personal.example"] }], |
| 102 | }); |
| 103 | ``` |
| 104 | |
| 105 | To silently discard unmatched mail instead, use a `drop` action: |
| 106 | |
| 107 | ```typescript |
| 108 | yield* 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 | |
| 117 | Both `Rule` and `CatchAll` accept a `worker` action whose value is |
| 118 | the deployed Worker script name (the `workerName` attribute of a |
| 119 | deployed Worker). Matched messages are delivered to that Worker's |
| 120 | `email` handler instead of a mailbox: |
| 121 | |
| 122 | ```typescript |
| 123 | yield* 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 | |
| 131 | Sending is the other direction. `Email.SendEmail` is a binding |
| 132 | descriptor, not a cloud resource — it names the binding and records |
| 133 | optional sender/destination restrictions that Cloudflare enforces at |
| 134 | send time: |
| 135 | |
| 136 | ```typescript |
| 137 | // src/email.ts |
| 138 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 139 | |
| 140 | export 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 |
| 147 | destination; `allowedDestinationAddresses` allows a list instead (the |
| 148 | two are mutually exclusive). `allowedSenderAddresses` restricts the |
| 149 | `from:` side. Omit all three to let the Worker send to any verified |
| 150 | destination on the account. |
| 151 | |
| 152 | Under `alchemy dev`, the binding is lowered onto a local email |
| 153 | simulator: `send()` validates the message — including the |
| 154 | sender/destination restrictions — and persists it as a `.eml` file |
| 155 | under `.alchemy/local/email` instead of delivering mail. Opt into the |
| 156 | live binding with |
| 157 | [`Alchemy.remote()`](/environments/local-development#running-a-resource-live-in-dev), |
| 158 | the same aspect that opts resources out of local emulation: |
| 159 | |
| 160 | ```typescript |
| 161 | import * as Alchemy from "alchemy"; |
| 162 | |
| 163 | export const Email = Cloudflare.Email.SendEmail("Email", { |
| 164 | allowedSenderAddresses: ["bot@example.com"], |
| 165 | destinationAddress: "you@personal.example", |
| 166 | }).pipe(Alchemy.remote()); |
| 167 | ``` |
| 168 | |
| 169 | :::caution |
| 170 | With `Alchemy.remote()`, `send()` from your dev loop sends real mail |
| 171 | from your domain — a made-up recipient address is still a real send |
| 172 | attempt. |
| 173 | ::: |
| 174 | |
| 175 | ## Send from the Worker |
| 176 | |
| 177 | Bind the descriptor with `Email.Send` in the Worker's Construction phase and |
| 178 | provide `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 |
| 184 | import * as Cloudflare from "alchemy/Cloudflare"; |
| 185 | import * as Effect from "effect/Effect"; |
| 186 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 187 | import { Email } from "./email.ts"; |
| 188 | |
| 189 | export 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 | |
| 216 | Cloudflare-side rejections (an unverified destination, a sender |
| 217 | outside the allowed list) surface as a typed `SendEmailError`, kept |
| 218 | in 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 |
| 221 | to the underlying runtime binding. |
| 222 | |
| 223 | ## Add the Worker to the Stack |
| 224 | |
| 225 | ```typescript |
| 226 | // alchemy.run.ts |
| 227 | import Notifier from "./src/notifier.ts"; |
| 228 | |
| 229 | export 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 | |
| 240 | Two constraints to keep in mind: the `from:` address must live on a |
| 241 | domain with Email Routing enabled (the `Routing` resource above), and |
| 242 | the `to:` address must be a verified destination on the account (the |
| 243 | `Address` resource above). Until the destination's verification link |
| 244 | is 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 | |
| 255 | Reference: |
| 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) |