| 1 | import type * as cf from "@cloudflare/workers-types"; |
| 2 | import * as Cause from "effect/Cause"; |
| 3 | import * as Context from "effect/Context"; |
| 4 | import * as Data from "effect/Data"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Layer from "effect/Layer"; |
| 7 | import type { Input } from "../../Input.ts"; |
| 8 | import * as Namespace from "../../Namespace.ts"; |
| 9 | import * as RemovalPolicy from "../../RemovalPolicy.ts"; |
| 10 | import { RuntimeContext } from "../../RuntimeContext.ts"; |
| 11 | import type { FunctionContext } from "../../Serverless/Function.ts"; |
| 12 | import { CatchAll } from "../Email/CatchAll.ts"; |
| 13 | import { Routing } from "../Email/Routing.ts"; |
| 14 | import { Rule, type Matcher } from "../Email/Rule.ts"; |
| 15 | import type { Reference } from "../Zone/lookup.ts"; |
| 16 | import { isWorkerEvent, Worker } from "./Worker.ts"; |
| 17 | |
| 18 | /** |
| 19 | * Effect-native wrapper around Cloudflare's |
| 20 | * [`ForwardableEmailMessage`](https://developers.cloudflare.com/email-routing/email-workers/runtime-api/#forwardableemailmessage). |
| 21 | * |
| 22 | * Follows the same shape as the other Cloudflare bindings (R2, KV, …): |
| 23 | * |
| 24 | * - `raw` is the underlying `cf.ForwardableEmailMessage` — an escape |
| 25 | * hatch for any field or future API not yet wrapped. |
| 26 | * - Ergonomic fields (`from`, `to`, `headers`, `body`, `bodySize`) are |
| 27 | * forwarded verbatim. |
| 28 | * - Action methods (`forward`, `reply`, `setReject`) return `Effect`s |
| 29 | * instead of `Promise`/`void`. |
| 30 | */ |
| 31 | export interface ForwardableEmailMessage { |
| 32 | /** Underlying Cloudflare message — escape hatch for unwrapped APIs. */ |
| 33 | readonly raw: cf.ForwardableEmailMessage; |
| 34 | /** Envelope From address. */ |
| 35 | readonly from: string; |
| 36 | /** Envelope To address. */ |
| 37 | readonly to: string; |
| 38 | /** RFC 5322 headers. */ |
| 39 | readonly headers: cf.Headers; |
| 40 | /** Raw message body stream (RFC 5322 wire bytes). */ |
| 41 | readonly body: cf.ReadableStream<Uint8Array>; |
| 42 | /** Size of the raw message body in bytes. */ |
| 43 | readonly bodySize: number; |
| 44 | /** |
| 45 | * Reject this message back to the connecting client with a permanent |
| 46 | * SMTP error and the given reason. |
| 47 | */ |
| 48 | setReject(reason: string): Effect.Effect<void>; |
| 49 | /** |
| 50 | * Forward this message to a verified destination address on the |
| 51 | * account. Fails with `EmailError` if Cloudflare rejects the forward |
| 52 | * (e.g. unverified destination). |
| 53 | */ |
| 54 | forward( |
| 55 | rcptTo: string, |
| 56 | headers?: cf.Headers, |
| 57 | ): Effect.Effect<void, EmailError>; |
| 58 | /** |
| 59 | * Reply to the sender with a new outbound message. Fails with |
| 60 | * `EmailError` if Cloudflare rejects the reply. |
| 61 | */ |
| 62 | reply(message: cf.EmailMessage): Effect.Effect<void, EmailError>; |
| 63 | } |
| 64 | |
| 65 | export class EmailError extends Data.TaggedError("EmailError")<{ |
| 66 | action: "forward" | "reply"; |
| 67 | message: string; |
| 68 | cause: unknown; |
| 69 | }> {} |
| 70 | |
| 71 | const wrap = (raw: cf.ForwardableEmailMessage): ForwardableEmailMessage => ({ |
| 72 | raw, |
| 73 | from: raw.from, |
| 74 | to: raw.to, |
| 75 | headers: raw.headers, |
| 76 | body: raw.raw, |
| 77 | bodySize: raw.rawSize, |
| 78 | setReject: (reason) => Effect.sync(() => raw.setReject(reason)), |
| 79 | forward: (rcptTo, headers) => |
| 80 | Effect.tryPromise({ |
| 81 | try: () => raw.forward(rcptTo, headers), |
| 82 | catch: (cause) => |
| 83 | new EmailError({ |
| 84 | action: "forward", |
| 85 | message: `Cloudflare email forward failed: ${formatCause(cause)}`, |
| 86 | cause, |
| 87 | }), |
| 88 | }), |
| 89 | reply: (msg) => |
| 90 | Effect.tryPromise({ |
| 91 | try: () => raw.reply(msg), |
| 92 | catch: (cause) => |
| 93 | new EmailError({ |
| 94 | action: "reply", |
| 95 | message: `Cloudflare email reply failed: ${formatCause(cause)}`, |
| 96 | cause, |
| 97 | }), |
| 98 | }), |
| 99 | }); |
| 100 | |
| 101 | /** |
| 102 | * Whether a subscription's matchers describe the zone's catch-all — either |
| 103 | * omitted entirely (the documented default) or a lone `{ type: "all" }`. |
| 104 | * Cloudflare models that as a per-zone singleton behind `/rules/catch_all`, |
| 105 | * so it maps to `Email.CatchAll` rather than `Email.Rule`. |
| 106 | */ |
| 107 | const isCatchAll = (matchers: Matcher[] | undefined): boolean => |
| 108 | matchers === undefined || |
| 109 | (matchers.length === 1 && matchers[0]?.type === "all"); |
| 110 | |
| 111 | const formatCause = (cause: unknown): string => |
| 112 | cause instanceof Error ? cause.message : String(cause); |
| 113 | |
| 114 | /** |
| 115 | * Settings for {@link email} — both halves of the consumer in one place. |
| 116 | * `zone` opts in to the deploy-time setup: an `Email.Routing` toggle on the |
| 117 | * zone plus the routing resource that hands matched mail to the host |
| 118 | * Worker. Omit `zone` to manage routing yourself. |
| 119 | * |
| 120 | * Which routing resource depends on {@link EmailSubscribeProps.matchers}: |
| 121 | * a catch-all subscription yields `Email.CatchAll` (Cloudflare models the |
| 122 | * zone catch-all as a singleton behind its own endpoint), anything more |
| 123 | * specific yields `Email.Rule`. |
| 124 | */ |
| 125 | export interface EmailSubscribeProps { |
| 126 | /** |
| 127 | * Zone to enable email routing on and attach the routing resource to. |
| 128 | * Accepts a zone id, a zone name (`example.com`), or a |
| 129 | * `{ zoneId, name? }` object (a `Cloudflare.Zone` resource works). |
| 130 | * Required to auto-create routing resources; omit if you're managing |
| 131 | * `Email.Routing` and `Email.Rule`/`Email.CatchAll` yourself. |
| 132 | */ |
| 133 | zone?: Input<Reference>; |
| 134 | /** |
| 135 | * Which envelopes Cloudflare delivers to this Worker. Ignored when |
| 136 | * `zone` is omitted. |
| 137 | * |
| 138 | * Omitting this (or passing a lone `{ type: "all" }`) subscribes to the |
| 139 | * zone's catch-all, provisioned as `Email.CatchAll`. There is exactly one |
| 140 | * catch-all per zone, so a second Worker subscribing to it takes the |
| 141 | * zone's mail from the first. |
| 142 | * |
| 143 | * @default [{ type: "all" }] |
| 144 | */ |
| 145 | matchers?: Matcher[]; |
| 146 | /** |
| 147 | * Display name for the auto-created `Email.Rule` / `Email.CatchAll`. |
| 148 | * |
| 149 | * @default the host worker's logical id |
| 150 | */ |
| 151 | ruleName?: string; |
| 152 | /** |
| 153 | * Priority of the auto-created `Email.Rule`. Lower numbers run first. |
| 154 | * Ignored for a catch-all subscription — the catch-all is always |
| 155 | * evaluated last. |
| 156 | * |
| 157 | * @default 0 |
| 158 | */ |
| 159 | priority?: number; |
| 160 | /** |
| 161 | * Whether the auto-created `Email.Rule` / `Email.CatchAll` is enabled. |
| 162 | * |
| 163 | * @default true |
| 164 | */ |
| 165 | enabled?: boolean; |
| 166 | } |
| 167 | |
| 168 | /** |
| 169 | * Subscribe to Cloudflare Email Worker events with an Effect handler. |
| 170 | * |
| 171 | * Wires both halves of the consumer in one call: |
| 172 | * |
| 173 | * - **Runtime**: registers an `email` event listener on the Worker. |
| 174 | * The handler receives a {@link ForwardableEmailMessage} whose |
| 175 | * action methods (`forward`, `reply`, `setReject`) return `Effect`s. |
| 176 | * - **Deploy-time** (when `zone` is set): yields a |
| 177 | * `Cloudflare.Email.Routing` toggle on the zone plus the routing |
| 178 | * resource whose `actions: [{ type: "worker", … }]` targets this |
| 179 | * Worker — `Cloudflare.Email.CatchAll` for a catch-all subscription, |
| 180 | * `Cloudflare.Email.Rule` for anything more specific. No manual |
| 181 | * wiring needed in `alchemy.run.ts`. |
| 182 | * |
| 183 | * Requires `EmailEventSourceLive` provided on the Worker's Effect. |
| 184 | * |
| 185 | * **Failure semantics**: a failing handler is logged and the failure is |
| 186 | * re-raised. Cloudflare turns that into a temporary SMTP failure, so the |
| 187 | * sending server keeps the message and retries later — mail is never |
| 188 | * accepted and then silently dropped. Handle the failures you consider |
| 189 | * final inside the handler (`Effect.retry`, `Effect.catchTag`, or |
| 190 | * `message.setReject(...)` to bounce permanently); anything you let |
| 191 | * escape becomes a retryable delivery failure. |
| 192 | * |
| 193 | * ### Subscribing to Inbound Mail |
| 194 | * **Example:** Catch-all on a zone — auto-creates routing + catch-all |
| 195 | * ```typescript |
| 196 | * import * as Cloudflare from "alchemy/Cloudflare"; |
| 197 | * import * as Effect from "effect/Effect"; |
| 198 | * |
| 199 | * export default Cloudflare.Worker( |
| 200 | * "Inbox", |
| 201 | * { main: import.meta.url }, |
| 202 | * Effect.gen(function* () { |
| 203 | * yield* Cloudflare.email({ zone: "example.com" }).subscribe( |
| 204 | * (message) => message.forward("ops@example.com"), |
| 205 | * ); |
| 206 | * return {}; |
| 207 | * }).pipe(Effect.provide(Cloudflare.EmailEventSourceLive)), |
| 208 | * ); |
| 209 | * ``` |
| 210 | * |
| 211 | * **Example:** Match a specific address |
| 212 | * ```typescript |
| 213 | * yield* Cloudflare.email({ |
| 214 | * zone: "example.com", |
| 215 | * matchers: [{ type: "literal", field: "to", value: "hello@example.com" }], |
| 216 | * }).subscribe((message) => message.forward("ops@example.com")); |
| 217 | * ``` |
| 218 | * |
| 219 | * **Example:** Reject (bounce) a message |
| 220 | * ```typescript |
| 221 | * yield* Cloudflare.email({ zone: "example.com" }).subscribe((message) => |
| 222 | * message.setReject("Mailbox closed"), |
| 223 | * ); |
| 224 | * ``` |
| 225 | * |
| 226 | * **Example:** Bring-your-own routing — no `zone`, no auto-create |
| 227 | * ```typescript |
| 228 | * // Manage `Email.Routing` / `Email.Rule` yourself in alchemy.run.ts. |
| 229 | * yield* Cloudflare.email().subscribe((message) => |
| 230 | * Effect.log(`from ${message.from}`), |
| 231 | * ); |
| 232 | * ``` |
| 233 | * |
| 234 | * @see https://developers.cloudflare.com/email-routing/email-workers/ |
| 235 | * |
| 236 | * @binding |
| 237 | * @product Workers |
| 238 | * @category Workers & Compute |
| 239 | */ |
| 240 | export const email = (props: EmailSubscribeProps = {}) => ({ |
| 241 | subscribe: <E = never, Req = never>( |
| 242 | process: (message: ForwardableEmailMessage) => Effect.Effect<void, E, Req>, |
| 243 | ) => EmailEventSource.use((source) => source(props, process)), |
| 244 | }); |
| 245 | |
| 246 | export type EmailEventSourceService = <E = never, Req = never>( |
| 247 | props: EmailSubscribeProps, |
| 248 | process: (message: ForwardableEmailMessage) => Effect.Effect<void, E, Req>, |
| 249 | ) => Effect.Effect<void, never, never>; |
| 250 | |
| 251 | export class EmailEventSource extends Context.Service< |
| 252 | EmailEventSource, |
| 253 | EmailEventSourceService |
| 254 | >()("Cloudflare.Workers.EmailEventSource") {} |
| 255 | |
| 256 | export const EmailEventSourceLive = Layer.effect( |
| 257 | EmailEventSource, |
| 258 | Effect.gen(function* () { |
| 259 | const host = yield* Worker; |
| 260 | return Effect.fn(function* <E, Req>( |
| 261 | props: EmailSubscribeProps, |
| 262 | process: ( |
| 263 | message: ForwardableEmailMessage, |
| 264 | ) => Effect.Effect<void, E, Req>, |
| 265 | ) { |
| 266 | // Deploy-time: provision the Email.Routing toggle plus the routing |
| 267 | // resource that hands matched mail to this Worker. Skipped once |
| 268 | // running inside the deployed Worker (the global guard) and when |
| 269 | // `zone` is omitted (bring-your-own routing). Namespaced under the |
| 270 | // host so logical identity is stable per Worker. |
| 271 | if (!globalThis.__ALCHEMY_RUNTIME__ && props.zone !== undefined) { |
| 272 | const zone = props.zone; |
| 273 | const matchers = props.matchers; |
| 274 | yield* Namespace.push( |
| 275 | host.LogicalId, |
| 276 | Effect.gen(function* () { |
| 277 | // Routing is a per-zone singleton shared with other rules on |
| 278 | // the zone, so destroying this Worker must not disable it. |
| 279 | yield* Routing("EmailRouting", { |
| 280 | zone, |
| 281 | enabled: true, |
| 282 | }).pipe(RemovalPolicy.retain()); |
| 283 | |
| 284 | const action = { |
| 285 | type: "worker" as const, |
| 286 | value: [host.workerName], |
| 287 | }; |
| 288 | |
| 289 | // Catch-all is a per-zone SINGLETON living behind |
| 290 | // `/rules/catch_all`, not an ordinary rule. Cloudflare surfaces |
| 291 | // it in `listRules` but rejects mutating it through the rule |
| 292 | // endpoint ("Invalid rule operation"), so creating it as an |
| 293 | // `Email.Rule` would produce a row the engine cannot delete. |
| 294 | // Route an all-matcher subscription to `Email.CatchAll` |
| 295 | // instead — the resource that owns that endpoint. |
| 296 | if (isCatchAll(matchers)) { |
| 297 | yield* CatchAll("EmailCatchAll", { |
| 298 | zone, |
| 299 | name: props.ruleName ?? host.LogicalId, |
| 300 | enabled: props.enabled ?? true, |
| 301 | actions: [action], |
| 302 | }); |
| 303 | return; |
| 304 | } |
| 305 | |
| 306 | yield* Rule("EmailRule", { |
| 307 | zone, |
| 308 | name: props.ruleName ?? host.LogicalId, |
| 309 | enabled: props.enabled ?? true, |
| 310 | priority: props.priority ?? 0, |
| 311 | matchers: matchers!, |
| 312 | actions: [action], |
| 313 | }); |
| 314 | }), |
| 315 | ); |
| 316 | } |
| 317 | |
| 318 | // Resolve the runtime context per-call rather than at layer |
| 319 | // construction (mirrors `Queues.EventSourceLive`). |
| 320 | const ctx = (yield* RuntimeContext) as unknown as FunctionContext; |
| 321 | yield* ctx.listen<void, Req>((event) => { |
| 322 | if (!isWorkerEvent(event) || event.type !== "email") return; |
| 323 | |
| 324 | const message = wrap(event.input as cf.ForwardableEmailMessage); |
| 325 | // Log, then let the failure propagate. Cloudflare turns an |
| 326 | // exception out of the `email` handler into a temporary SMTP |
| 327 | // failure, so the sending server keeps the message and retries. |
| 328 | // Swallowing it here would instead ACCEPT the envelope and then |
| 329 | // drop it: a transient dependency outage would silently destroy |
| 330 | // mail with nothing left to retry from. |
| 331 | return process(message).pipe( |
| 332 | Effect.tapCause((cause) => |
| 333 | Effect.sync(() => { |
| 334 | console.error( |
| 335 | `[EmailEventSource] handler failed for message to ` + |
| 336 | `"${message.to}": ${Cause.pretty(cause)}`, |
| 337 | ); |
| 338 | }), |
| 339 | ), |
| 340 | // The listener contract is `E = never`, so surface the failure as |
| 341 | // a defect rather than discarding it: the invocation fails, which |
| 342 | // is what Cloudflare turns into the temporary SMTP failure. |
| 343 | Effect.orDie, |
| 344 | ); |
| 345 | }); |
| 346 | }) as EmailEventSourceService; |
| 347 | }), |
| 348 | ); |