1import type * as cf from "@cloudflare/workers-types";
2import * as Cause from "effect/Cause";
3import * as Context from "effect/Context";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import type { Input } from "../../Input.ts";
8import * as Namespace from "../../Namespace.ts";
9import * as RemovalPolicy from "../../RemovalPolicy.ts";
10import { RuntimeContext } from "../../RuntimeContext.ts";
11import type { FunctionContext } from "../../Serverless/Function.ts";
12import { CatchAll } from "../Email/CatchAll.ts";
13import { Routing } from "../Email/Routing.ts";
14import { Rule, type Matcher } from "../Email/Rule.ts";
15import type { Reference } from "../Zone/lookup.ts";
16import { 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 */
31export 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
65export class EmailError extends Data.TaggedError("EmailError")<{
66 action: "forward" | "reply";
67 message: string;
68 cause: unknown;
69}> {}
70
71const 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 */
107const isCatchAll = (matchers: Matcher[] | undefined): boolean =>
108 matchers === undefined ||
109 (matchers.length === 1 && matchers[0]?.type === "all");
110
111const 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 */
125export 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 */
240export 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
246export 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
251export class EmailEventSource extends Context.Service<
252 EmailEventSource,
253 EmailEventSourceService
254>()("Cloudflare.Workers.EmailEventSource") {}
255
256export 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);