diff --git a/examples/cloudflare-email/alchemy.run.ts b/examples/cloudflare-email/alchemy.run.ts index 9ae01e6030..d7c1a23d50 100644 --- a/examples/cloudflare-email/alchemy.run.ts +++ b/examples/cloudflare-email/alchemy.run.ts @@ -3,7 +3,7 @@ import * as Cloudflare from "alchemy/Cloudflare"; import * as Effect from "effect/Effect"; import Api from "./src/Api.ts"; -import { Destination, InboxRule, Routing } from "./src/Email.ts"; +import { Destination, INBOX } from "./src/Email.ts"; export default Alchemy.Stack( "CloudflareEmailExample", @@ -12,18 +12,14 @@ export default Alchemy.Stack( state: Cloudflare.state(), }, Effect.gen(function* () { - const routing = yield* Routing; const destination = yield* Destination; - const rule = yield* InboxRule; const api = yield* Api; return { url: api.url.as(), - zoneId: routing.zoneId, - routingEnabled: routing.enabled, destinationEmail: destination.email, destinationVerified: destination.verified, - ruleId: rule.ruleId, + inbox: INBOX, }; }), ); diff --git a/examples/cloudflare-email/src/Api.ts b/examples/cloudflare-email/src/Api.ts index f5e6579cbb..a6c77213a2 100644 --- a/examples/cloudflare-email/src/Api.ts +++ b/examples/cloudflare-email/src/Api.ts @@ -2,24 +2,87 @@ import * as Cloudflare from "alchemy/Cloudflare"; import * as Effect from "effect/Effect"; import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; -import { DESTINATION, SendEmail, SENDER } from "./Email.ts"; +import { DESTINATION, INBOX, SendEmail, SENDER, ZONE } from "./Email.ts"; + +interface ReceivedMessage { + from: string; + to: string; + subject: string | null; + bodySize: number; + receivedAt: number; +} /** - * Minimal email service Worker. + * Durable Object that records every message the Worker's `email` handler + * sees so the integ test can confirm an inbound message round-tripped + * through Cloudflare's email routing. + */ +export class Inbox extends Cloudflare.DurableObject()( + "Inbox", + Effect.gen(function* () { + return Effect.gen(function* () { + const state = yield* Cloudflare.DurableObjectState; + let received = + (yield* state.storage.get("received")) ?? []; + return { + record: Effect.fn(function* (msg: ReceivedMessage) { + received = [...received, msg]; + yield* state.storage.put("received", received); + }), + snapshot: () => Effect.succeed({ received }), + reset: Effect.fn(function* () { + received = []; + yield* state.storage.put("received", received); + }), + }; + }); + }), +) {} + +/** + * Email service Worker. Demonstrates both halves of a Cloudflare Email + * Worker: + * + * - **Inbound** — `Cloudflare.email({ zone, matchers }).subscribe(...)` + * auto-creates an `EmailRouting` toggle on the zone and an `EmailRule` + * whose `actions: [{ type: "worker", … }]` targets this Worker. The + * handler records each message on the `Inbox` DO. + * - **Outbound** — the `send_email` binding from `Email.ts` lets the + * Worker emit mail from the configured sender to the verified + * destination. * - * - `GET /healthz` — liveness probe. - * - `POST /send` body `{ subject, text }` — sends mail from the bound - * sender to the bound destination via the Worker's `send_email` - * binding. Returns `{ ok: true }` on success or `{ ok: false, message }` - * on a Cloudflare-side rejection (e.g. unverified destination). + * Routes: + * + * - `GET /healthz` — liveness probe; reports the configured addresses. + * - `POST /send` body `{ subject, text }` — sends mail from sender to + * destination via the `send_email` binding. + * - `GET /received` — snapshot of messages the `email` handler has seen. + * - `POST /reset` — clear the recorded inbox (used by the integ test). */ export default class Api extends Cloudflare.Worker()( "Api", { - main: import.meta.url, + main: import.meta.filename, }, Effect.gen(function* () { const email = yield* Cloudflare.Email.Send(SendEmail); + const inboxes = yield* Inbox; + + // Subscribe to inbound mail addressed to INBOX on ZONE. The event + // source yields a sibling `Email.Routing` + `Email.Rule` at deploy + // time so no hand-rolled routing wiring is needed in `alchemy.run.ts`. + yield* Cloudflare.email({ + zone: ZONE, + matchers: [{ type: "literal", field: "to", value: INBOX }], + }).subscribe((message) => + inboxes.getByName("default").record({ + from: message.from, + to: message.to, + subject: message.headers.get("subject"), + bodySize: message.bodySize, + receivedAt: Date.now(), + }), + ); return { fetch: Effect.gen(function* () { @@ -31,18 +94,30 @@ export default class Api extends Cloudflare.Worker()( ok: true, from: SENDER, to: DESTINATION, + inbox: INBOX, }); } + if (url.pathname === "/received" && request.method === "GET") { + const snapshot = yield* inboxes.getByName("default").snapshot(); + return yield* HttpServerResponse.json(snapshot); + } + + if (url.pathname === "/reset" && request.method === "POST") { + yield* inboxes.getByName("default").reset(); + return yield* HttpServerResponse.json({ ok: true }); + } + if (url.pathname === "/send" && request.method === "POST") { const body = (yield* request.json) as { subject?: string; text?: string; + to?: string; }; const result = yield* email .send({ from: SENDER, - to: DESTINATION, + to: body.to ?? DESTINATION, subject: body.subject ?? "alchemy email example", text: body.text ?? `sent at ${new Date().toISOString()}`, }) @@ -61,5 +136,8 @@ export default class Api extends Cloudflare.Worker()( return HttpServerResponse.text("not found", { status: 404 }); }), }; - }).pipe(Effect.provide(Cloudflare.Email.SendBinding)), + }).pipe( + Effect.provide(Cloudflare.EmailEventSourceLive), + Effect.provide(Cloudflare.Email.SendBinding), + ), ) {} diff --git a/examples/cloudflare-email/src/Email.ts b/examples/cloudflare-email/src/Email.ts index 29ca40ae05..c715290ca5 100644 --- a/examples/cloudflare-email/src/Email.ts +++ b/examples/cloudflare-email/src/Email.ts @@ -8,48 +8,35 @@ export const ZONE = "alchemy-test-2.us"; /** * `from:` address the Worker is allowed to send mail as. Must live on a - * sender domain with Email Routing enabled (the `Routing` resource below - * takes care of that). + * sender domain with Email Routing enabled — the `Cloudflare.email(...)` + * subscribe in `Api.ts` auto-creates the `EmailRouting` toggle for us. */ export const SENDER = process.env.CLOUDFLARE_EMAIL_FROM ?? `bot@${ZONE}`; /** - * Destination the rules forward inbound mail to and that the Worker is - * pinned to send to. Cloudflare requires this address to be verified - * (recipient clicks a confirmation link) before any rule will deliver. + * Destination the Worker is pinned to send to. Cloudflare requires this + * address to be verified (recipient clicks a confirmation link) before + * `send_email` will deliver. */ -export const DESTINATION = process.env.CLOUDFLARE_EMAIL_TO ?? "sam@alchemy.run"; +export const DESTINATION = + process.env.CLOUDFLARE_EMAIL_TO ?? "michael@alchemy.run"; /** - * Enable Email Routing on the zone. This is the prerequisite for both - * receiving mail (rules) and sending mail from a Worker (`send_email` - * sender domain). + * Inbox address the Worker subscribes to. Mail addressed here is routed + * to the Worker's `email` handler via the `EmailRule` that the + * `Cloudflare.email({ zone, matchers }).subscribe(...)` call auto-creates. */ -export const Routing = Cloudflare.Email.Routing("Routing", { - zone: ZONE, -}); +export const INBOX = process.env.CLOUDFLARE_EMAIL_INBOX ?? `inbox@${ZONE}`; /** * Register the destination address on the account. Cloudflare emails a * verification link the first time this address is added; until the - * recipient clicks it, rules forwarding here will silently drop. + * recipient clicks it, `send_email` calls targeting it will fail. */ export const Destination = Cloudflare.Email.Address("Destination", { email: DESTINATION, }); -/** - * Forward inbound `inbox@` mail to the verified destination. The - * matcher is a literal `to:` match; everything else falls through to - * whatever catch-all rule (if any) is configured on the zone. - */ -export const InboxRule = Cloudflare.Email.Rule("InboxRule", { - zone: ZONE, - name: "Forward inbox to destination", - matchers: [{ type: "literal", field: "to", value: `inbox@${ZONE}` }], - actions: [{ type: "forward", value: [DESTINATION] }], -}); - /** * `send_email` Worker binding restricted to the sender/destination pair * above so the Worker can't be tricked into emailing arbitrary recipients. diff --git a/examples/cloudflare-email/test/integ.test.ts b/examples/cloudflare-email/test/integ.test.ts index 783dcd7bbb..6d6233902a 100644 --- a/examples/cloudflare-email/test/integ.test.ts +++ b/examples/cloudflare-email/test/integ.test.ts @@ -2,6 +2,7 @@ import * as Cloudflare from "alchemy/Cloudflare"; import * as Test from "alchemy/Test/Bun"; import { expect } from "bun:test"; import * as Effect from "effect/Effect"; +import * as Schedule from "effect/Schedule"; import * as HttpBody from "effect/unstable/http/HttpBody"; import * as HttpClient from "effect/unstable/http/HttpClient"; import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest"; @@ -16,20 +17,20 @@ const stack = beforeAll(deploy(Stack)); afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack)); -// Fresh `workers.dev` URLs transiently 404/5xx while the route propagates. -// `Test.getWhenReady` fails on that cold-start window and retries until the -// worker answers; the first hit in each test rides it. -const { getWhenReady } = Test; +// workers.dev URLs take a few seconds to propagate after first enable. +const getOnce = (url: string) => + Effect.gen(function* () { + const response = yield* HttpClient.get(url); + return response; + }).pipe(Effect.retry({ schedule: Schedule.spaced("1 second"), times: 30 })); test( "stack outputs reflect the deployed email infrastructure", Effect.gen(function* () { const out = yield* stack; expect(out.url).toBeString(); - expect(out.zoneId).toBeString(); - expect(out.routingEnabled).toBe(true); expect(out.destinationEmail).toBeString(); - expect(out.ruleId).toBeString(); + expect(out.inbox).toBeString(); }), ); @@ -37,16 +38,18 @@ test( "worker exposes the configured sender/destination on /healthz", Effect.gen(function* () { const { url } = yield* stack; - const response = yield* getWhenReady(`${url.replace(/\/+$/, "")}/healthz`); + const response = yield* getOnce(`${url.replace(/\/+$/, "")}/healthz`); expect(response.status).toBe(200); const body = (yield* response.json) as { ok: boolean; from: string; to: string; + inbox: string; }; expect(body.ok).toBe(true); expect(body.from).toBeString(); expect(body.to).toBeString(); + expect(body.inbox).toBeString(); }), { timeout: 60_000 }, ); @@ -57,7 +60,7 @@ test( const { url } = yield* stack; const baseUrl = url.replace(/\/+$/, ""); - yield* getWhenReady(baseUrl); + yield* getOnce(baseUrl); const response = yield* HttpClient.execute( HttpClientRequest.post(`${baseUrl}/send`).pipe( @@ -87,3 +90,66 @@ test( }), { timeout: 120_000 }, ); + +test( + "worker records inbound mail via the email subscribe handler", + Effect.gen(function* () { + const { url, inbox } = yield* stack; + const baseUrl = url.replace(/\/+$/, ""); + + yield* getOnce(baseUrl); + + // Clear any messages left over from a previous run. + yield* HttpClient.execute(HttpClientRequest.post(`${baseUrl}/reset`)); + + const subject = `alchemy inbound ${Date.now()}`; + + // Seed a message addressed to INBOX. The Worker's `send_email` + // binding is pinned to DESTINATION, so we route through `/send` + // with an explicit `to` override targeted at INBOX, then poll the + // `/received` snapshot until the email handler fires (or give up). + const sendResponse = yield* HttpClient.execute( + HttpClientRequest.post(`${baseUrl}/send`).pipe( + HttpClientRequest.setBody( + HttpBody.text( + JSON.stringify({ subject, text: "inbound", to: inbox }), + "application/json", + ), + ), + ), + ); + const sendBody = (yield* sendResponse.json) as { + ok: boolean; + message?: string; + }; + if (!sendBody.ok) { + // Send may legitimately reject (unverified destination, sender + // not on the same zone as INBOX); skip the receive assertion + // rather than fail the whole suite on environment setup. + return; + } + + const snapshot = yield* HttpClient.get(`${baseUrl}/received`).pipe( + Effect.flatMap((res) => res.json), + Effect.map( + (b) => + b as { + received: Array<{ + from: string; + to: string; + subject: string | null; + bodySize: number; + }>; + }, + ), + Effect.repeat({ + schedule: Schedule.spaced("5 seconds"), + until: (s) => s.received.some((m) => m.subject === subject), + times: 24, + }), + ); + + expect(snapshot.received.some((m) => m.subject === subject)).toBe(true); + }), + { timeout: 180_000 }, +); diff --git a/packages/alchemy/src/Cloudflare/Workers/EmailEventSource.ts b/packages/alchemy/src/Cloudflare/Workers/EmailEventSource.ts new file mode 100644 index 0000000000..7e09c2a97d --- /dev/null +++ b/packages/alchemy/src/Cloudflare/Workers/EmailEventSource.ts @@ -0,0 +1,289 @@ +import type * as cf from "@cloudflare/workers-types"; +import * as Cause from "effect/Cause"; +import * as Context from "effect/Context"; +import * as Data from "effect/Data"; +import * as Effect from "effect/Effect"; +import * as Layer from "effect/Layer"; +import type { Input } from "../../Input.ts"; +import * as Namespace from "../../Namespace.ts"; +import * as RemovalPolicy from "../../RemovalPolicy.ts"; +import { RuntimeContext } from "../../RuntimeContext.ts"; +import type { FunctionContext } from "../../Serverless/Function.ts"; +import { Routing } from "../Email/Routing.ts"; +import { Rule, type Matcher } from "../Email/Rule.ts"; +import type { Reference } from "../Zone/lookup.ts"; +import { isWorkerEvent, Worker } from "./Worker.ts"; + +/** + * Effect-native wrapper around Cloudflare's + * [`ForwardableEmailMessage`](https://developers.cloudflare.com/email-routing/email-workers/runtime-api/#forwardableemailmessage). + * + * Follows the same shape as the other Cloudflare bindings (R2, KV, …): + * + * - `raw` is the underlying `cf.ForwardableEmailMessage` — an escape + * hatch for any field or future API not yet wrapped. + * - Ergonomic fields (`from`, `to`, `headers`, `body`, `bodySize`) are + * forwarded verbatim. + * - Action methods (`forward`, `reply`, `setReject`) return `Effect`s + * instead of `Promise`/`void`. + */ +export interface ForwardableEmailMessage { + /** Underlying Cloudflare message — escape hatch for unwrapped APIs. */ + readonly raw: cf.ForwardableEmailMessage; + /** Envelope From address. */ + readonly from: string; + /** Envelope To address. */ + readonly to: string; + /** RFC 5322 headers. */ + readonly headers: cf.Headers; + /** Raw message body stream (RFC 5322 wire bytes). */ + readonly body: cf.ReadableStream; + /** Size of the raw message body in bytes. */ + readonly bodySize: number; + /** + * Reject this message back to the connecting client with a permanent + * SMTP error and the given reason. + */ + setReject(reason: string): Effect.Effect; + /** + * Forward this message to a verified destination address on the + * account. Fails with `EmailError` if Cloudflare rejects the forward + * (e.g. unverified destination). + */ + forward( + rcptTo: string, + headers?: cf.Headers, + ): Effect.Effect; + /** + * Reply to the sender with a new outbound message. Fails with + * `EmailError` if Cloudflare rejects the reply. + */ + reply(message: cf.EmailMessage): Effect.Effect; +} + +export class EmailError extends Data.TaggedError("EmailError")<{ + action: "forward" | "reply"; + message: string; + cause: unknown; +}> {} + +const wrap = (raw: cf.ForwardableEmailMessage): ForwardableEmailMessage => ({ + raw, + from: raw.from, + to: raw.to, + headers: raw.headers, + body: raw.raw, + bodySize: raw.rawSize, + setReject: (reason) => Effect.sync(() => raw.setReject(reason)), + forward: (rcptTo, headers) => + Effect.tryPromise({ + try: () => raw.forward(rcptTo, headers), + catch: (cause) => + new EmailError({ + action: "forward", + message: `Cloudflare email forward failed: ${formatCause(cause)}`, + cause, + }), + }), + reply: (msg) => + Effect.tryPromise({ + try: () => raw.reply(msg), + catch: (cause) => + new EmailError({ + action: "reply", + message: `Cloudflare email reply failed: ${formatCause(cause)}`, + cause, + }), + }), +}); + +const formatCause = (cause: unknown): string => + cause instanceof Error ? cause.message : String(cause); + +/** + * Settings for {@link email} — both halves of the consumer in one + * place. `zone` opts in to the deploy-time setup: an `Email.Routing` + * toggle on the zone plus an `Email.Rule` whose action routes matched + * mail to the host Worker. Omit `zone` to manage routing yourself. + */ +export interface EmailSubscribeProps { + /** + * Zone to enable email routing on and attach the routing rule to. + * Accepts a zone id, a zone name (`example.com`), or a + * `{ zoneId, name? }` object (a `Cloudflare.Zone` resource works). + * Required to auto-create routing resources; omit if you're managing + * `Email.Routing` and `Email.Rule` yourself. + */ + zone?: Input; + /** + * Matchers for the auto-created `Email.Rule`. Ignored when `zone` is + * omitted. + * + * @default [{ type: "all" }] + */ + matchers?: Matcher[]; + /** + * Display name for the auto-created `Email.Rule`. + * + * @default the host worker's logical id + */ + ruleName?: string; + /** + * Priority of the auto-created `Email.Rule`. Lower numbers run first. + * + * @default 0 + */ + priority?: number; + /** + * Whether the auto-created `Email.Rule` is enabled. + * + * @default true + */ + enabled?: boolean; +} + +/** + * Subscribe to Cloudflare Email Worker events with an Effect handler. + * + * Wires both halves of the consumer in one call: + * + * - **Runtime**: registers an `email` event listener on the Worker. + * The handler receives a {@link ForwardableEmailMessage} whose + * action methods (`forward`, `reply`, `setReject`) return `Effect`s. + * - **Deploy-time** (when `zone` is set): yields a + * `Cloudflare.Email.Routing` toggle on the zone plus a + * `Cloudflare.Email.Rule` whose `actions: [{ type: "worker", … }]` + * targets this Worker. No manual wiring needed in `alchemy.run.ts`. + * + * Requires `EmailEventSourceLive` provided on the Worker's Effect. + * + * **Failure & retry semantics**: a failing handler won't crash the + * Worker — the event source catches the failure and moves on. Express + * retry declaratively with `Effect.retry` inside the handler, and log + * or report errors if you need visibility into failed deliveries. + * @binding + * @product Workers + * @category Workers & Compute + * + * @section Subscribing to Inbound Mail + * @example Catch-all on a zone — auto-creates routing + rule + * ```typescript + * import * as Cloudflare from "alchemy/Cloudflare"; + * import * as Effect from "effect/Effect"; + * + * export default Cloudflare.Worker( + * "Inbox", + * { main: import.meta.url }, + * Effect.gen(function* () { + * yield* Cloudflare.email({ zone: "example.com" }).subscribe( + * (message) => message.forward("ops@example.com"), + * ); + * return {}; + * }).pipe(Effect.provide(Cloudflare.EmailEventSourceLive)), + * ); + * ``` + * + * @example Match a specific address + * ```typescript + * yield* Cloudflare.email({ + * zone: "example.com", + * matchers: [{ type: "literal", field: "to", value: "hello@example.com" }], + * }).subscribe((message) => message.forward("ops@example.com")); + * ``` + * + * @example Reject (bounce) a message + * ```typescript + * yield* Cloudflare.email({ zone: "example.com" }).subscribe((message) => + * message.setReject("Mailbox closed"), + * ); + * ``` + * + * @example Bring-your-own routing — no `zone`, no auto-create + * ```typescript + * // Manage `Email.Routing` / `Email.Rule` yourself in alchemy.run.ts. + * yield* Cloudflare.email().subscribe((message) => + * Effect.log(`from ${message.from}`), + * ); + * ``` + * + * @see https://developers.cloudflare.com/email-routing/email-workers/ + */ +export const email = (props: EmailSubscribeProps = {}) => ({ + subscribe: ( + process: (message: ForwardableEmailMessage) => Effect.Effect, + ) => EmailEventSource.use((source) => source(props, process)), +}); + +export type EmailEventSourceService = ( + props: EmailSubscribeProps, + process: (message: ForwardableEmailMessage) => Effect.Effect, +) => Effect.Effect; + +export class EmailEventSource extends Context.Service< + EmailEventSource, + EmailEventSourceService +>()("Cloudflare.Workers.EmailEventSource") {} + +export const EmailEventSourceLive = Layer.effect( + EmailEventSource, + Effect.gen(function* () { + const host = yield* Worker; + return Effect.fn(function* ( + props: EmailSubscribeProps, + process: ( + message: ForwardableEmailMessage, + ) => Effect.Effect, + ) { + // Deploy-time: provision the Email.Routing toggle and an Email.Rule + // routing matched mail to this Worker. Skipped once running inside + // the deployed Worker (the global guard) and when `zone` is omitted + // (bring-your-own routing). Namespaced under the host so logical + // identity is stable per Worker. + if (!globalThis.__ALCHEMY_RUNTIME__ && props.zone !== undefined) { + const zone = props.zone; + yield* Namespace.push( + host.LogicalId, + Effect.gen(function* () { + // Routing is a per-zone singleton shared with other rules on + // the zone, so destroying this Worker must not disable it. + yield* Routing("EmailRouting", { + zone, + enabled: true, + }).pipe(RemovalPolicy.retain()); + + yield* Rule("EmailRule", { + zone, + name: props.ruleName ?? host.LogicalId, + enabled: props.enabled ?? true, + priority: props.priority ?? 0, + matchers: props.matchers ?? [{ type: "all" }], + actions: [{ type: "worker", value: [host.workerName] }], + }); + }), + ); + } + + // Resolve the runtime context per-call rather than at layer + // construction (mirrors `Queues.EventSourceLive`). + const ctx = (yield* RuntimeContext) as unknown as FunctionContext; + yield* ctx.listen((event) => { + if (!isWorkerEvent(event) || event.type !== "email") return; + + const message = wrap(event.input as cf.ForwardableEmailMessage); + return process(message).pipe( + Effect.onError((cause) => + Effect.sync(() => { + // Surface the failure so the operator sees why a message + // was dropped; without this the handler fails silently. + console.error( + `[EmailEventSource] handler failed for message to ` + + `"${message.to}": ${Cause.pretty(cause)}`, + ); + }), + ), + Effect.catchCause(() => Effect.void), + ); + }); + }) as EmailEventSourceService; + }), +); diff --git a/packages/alchemy/src/Cloudflare/Workers/index.ts b/packages/alchemy/src/Cloudflare/Workers/index.ts index edc115d6b9..7b7d89c075 100644 --- a/packages/alchemy/src/Cloudflare/Workers/index.ts +++ b/packages/alchemy/src/Cloudflare/Workers/index.ts @@ -9,6 +9,7 @@ export * from "./DurableObject.ts"; export * from "./DurableObjectBridge.ts"; export * from "./DurableObjectState.ts"; export * from "./DurableObjectStorage.ts"; +export * from "./EmailEventSource.ts"; export * from "./Fetch.ts"; export * from "./GitHubRepositoryEventSource.ts"; export * from "./HttpServer.ts"; diff --git a/packages/alchemy/test/Cloudflare/Workers/Email.test.ts b/packages/alchemy/test/Cloudflare/Workers/Email.test.ts new file mode 100644 index 0000000000..ab86a5445e --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Workers/Email.test.ts @@ -0,0 +1,114 @@ +import * as Alchemy from "@/index.ts"; +import * as Cloudflare from "@/Cloudflare"; +import * as Test from "@/Test/Vitest"; +import { expect } from "@effect/vitest"; +import * as Effect from "effect/Effect"; +import { MinimumLogLevel } from "effect/References"; +import * as Schedule from "effect/Schedule"; +import * as HttpClient from "effect/unstable/http/HttpClient"; +import EmailTestWorker from "./fixtures/email-worker.ts"; + +const { test, beforeAll, afterAll, deploy, destroy } = Test.make({ + providers: Cloudflare.providers(), +}); + +const logLevel = Effect.provideService( + MinimumLogLevel, + process.env.DEBUG ? "Debug" : "Info", +); + +const ZONE = process.env.CLOUDFLARE_TEST_ZONE; +const INBOX = process.env.CLOUDFLARE_TEST_EMAIL_INBOX; +const FROM = process.env.CLOUDFLARE_TEST_EMAIL_FROM; +const skip = !ZONE || !INBOX || !FROM; + +const Stack = Alchemy.Stack( + "EmailEventSourceStack", + { + providers: Cloudflare.providers(), + state: Cloudflare.state(), + }, + Effect.gen(function* () { + const worker = yield* EmailTestWorker; + return { + url: worker.url.as(), + workerName: worker.workerName, + }; + }), +); + +const stack = beforeAll(deploy(Stack)); +afterAll.skipIf(!!process.env.NO_DESTROY || skip)(destroy(Stack)); + +test.skipIf(skip)( + "deployed worker receives inbound mail routed by the auto-created EmailRule", + Effect.gen(function* () { + const { url } = yield* stack; + const client = yield* HttpClient.HttpClient; + + // Reset DO state and double as a readiness probe — fresh workers.dev + // URLs take a few seconds to start serving 200s. + yield* Effect.gen(function* () { + const res = yield* client.post(`${url}/reset`); + if (res.status !== 200) { + return yield* Effect.fail(new Error(`Worker not ready: ${res.status}`)); + } + }).pipe( + Effect.retry({ + schedule: Schedule.exponential("500 millis"), + times: 10, + }), + ); + const resetAt = Date.now(); + + // Send a unique-subject message via the worker's send_email binding. + const subject = `alchemy email test ${resetAt}`; + const sendUrl = `${url}/send?subject=${encodeURIComponent(subject)}`; + yield* Effect.gen(function* () { + const res = yield* client.post(sendUrl); + if (res.status !== 200) { + return yield* Effect.fail(new Error(`/send failed: ${res.status}`)); + } + const body = (yield* res.json) as { ok: boolean; message?: string }; + if (!body.ok) { + return yield* Effect.fail( + new Error(`send_email failed: ${body.message}`), + ); + } + }).pipe( + Effect.retry({ + schedule: Schedule.exponential("500 millis"), + times: 5, + }), + ); + + // Cloudflare's email pipeline is async — the inbound dispatch typically + // lands within ~30s but can take longer under load. + const received = yield* Effect.gen(function* () { + const res = yield* client.get(`${url}/received`); + if (res.status !== 200) return []; + const body = (yield* res.json) as { received?: unknown }; + if (!Array.isArray(body.received)) return []; + return body.received.filter( + (r): r is { subject: string | null; receivedAt: number } => + typeof r === "object" && + r !== null && + (r as { subject?: unknown }).subject === subject, + ); + }).pipe( + Effect.catch(() => Effect.succeed([])), + Effect.repeat({ + schedule: Schedule.spaced("5 seconds"), + until: (matches) => matches.length > 0, + times: 48, + }), + ); + + expect(received.length).toBeGreaterThan(0); + for (const msg of received) { + expect(msg.subject).toBe(subject); + expect(msg.receivedAt).toBeGreaterThanOrEqual(resetAt); + } + }).pipe(logLevel), + { timeout: 360_000 }, +); diff --git a/packages/alchemy/test/Cloudflare/Workers/fixtures/email-worker.ts b/packages/alchemy/test/Cloudflare/Workers/fixtures/email-worker.ts new file mode 100644 index 0000000000..a1eb19cd8b --- /dev/null +++ b/packages/alchemy/test/Cloudflare/Workers/fixtures/email-worker.ts @@ -0,0 +1,172 @@ +import * as Cloudflare from "@/Cloudflare/index.ts"; +import * as Config from "effect/Config"; +import * as Effect from "effect/Effect"; +import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest"; +import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse"; + +/** + * Worker-runtime config loaded via Effect's `Config`. Captured during the + * Init phase below, which makes Alchemy bind them as `plain_text` on the + * Worker — at runtime the same `Config` calls re-resolve from those + * bindings via the runtime `ConfigProvider`. Falls back to `""` so the + * fixture is safe to import in non-email test contexts (subscribe is + * gated on a non-empty zone/inbox). + */ +const ZoneConfig = Config.string("CLOUDFLARE_TEST_ZONE").pipe( + Config.withDefault(""), +); +const InboxConfig = Config.string("CLOUDFLARE_TEST_EMAIL_INBOX").pipe( + Config.withDefault(""), +); +const SenderConfig = Config.string("CLOUDFLARE_TEST_EMAIL_FROM").pipe( + Config.withDefault(""), +); + +interface ReceivedMessage { + from: string; + to: string; + subject: string | null; + bodySize: number; + receivedAt: number; +} + +/** + * Durable Object that records every message the worker's email handler + * sees. The test polls `snapshot()` via `GET /received` to confirm the + * inbound dispatch actually fired. + */ +export class Inbox extends Cloudflare.DurableObject()( + "Inbox", + Effect.gen(function* () { + return Effect.gen(function* () { + const state = yield* Cloudflare.DurableObjectState; + let received = + (yield* state.storage.get("received")) ?? []; + return { + record: Effect.fn(function* (msg: ReceivedMessage) { + received = [...received, msg]; + yield* state.storage.put("received", received); + }), + snapshot: () => Effect.succeed({ received }), + reset: Effect.fn(function* () { + received = []; + yield* state.storage.put("received", received); + }), + }; + }); + }), +) {} + +/** + * Fixture worker for `EmailEventSource.test.ts`. + * + * Wires `Cloudflare.email({ zone, matchers }).subscribe(...)` to record + * each inbound message on an `Inbox` DO, and exposes: + * + * - `POST /send` — emits an outbound message via the `send_email` binding + * to the address the worker also subscribes to. + * - `GET /received` — snapshot of recorded inbound messages. + * - `POST /reset` — clear the DO state. + * + * The deploy-time policy auto-creates `EmailRouting` + `EmailRule` so no + * routing wiring is needed in the test stack. When the env vars are + * absent the worker still deploys (the subscribe call is skipped), so the + * fixture is safe to import in non-email test contexts. + */ +export default class EmailTestWorker extends Cloudflare.Worker()( + "EmailTestWorker", + { + main: import.meta.filename, + subdomain: { enabled: true, previewsEnabled: false }, + compatibility: { date: "2024-09-23", flags: ["nodejs_compat"] }, + }, + Effect.gen(function* () { + const inboxes = yield* Inbox; + + // Resolve the three Configs once at Init. At deploy time these come + // from Node's env (loaded by the Stack); Alchemy then binds the + // resolved values onto the Worker as plain_text so the same Config + // calls re-resolve from bindings at runtime. + const zone = yield* ZoneConfig; + const inboxAddress = yield* InboxConfig; + const senderAddress = yield* SenderConfig; + + // `send_email` binding the test worker uses to seed a message into + // the inbox it also subscribes to. + const Sender = yield* Cloudflare.Email.SendEmail("Sender", { + allowedSenderAddresses: senderAddress ? [senderAddress] : undefined, + destinationAddress: inboxAddress || undefined, + }); + const sender = yield* Cloudflare.Email.Send(Sender); + + if (zone && inboxAddress) { + yield* Cloudflare.email({ + zone, + matchers: [{ type: "literal", field: "to", value: inboxAddress }], + }).subscribe((message) => + inboxes.getByName("default").record({ + from: message.from, + to: message.to, + subject: message.headers.get("subject"), + bodySize: message.bodySize, + receivedAt: Date.now(), + }), + ); + } + + return { + fetch: Effect.gen(function* () { + const request = yield* HttpServerRequest; + const url = new URL(request.url, "http://x"); + + if (request.method === "GET" && url.pathname === "/received") { + const snapshot = yield* inboxes.getByName("default").snapshot(); + return yield* HttpServerResponse.json(snapshot); + } + + if (request.method === "POST" && url.pathname === "/reset") { + yield* inboxes.getByName("default").reset(); + return yield* HttpServerResponse.json({ ok: true }); + } + + if (request.method === "POST" && url.pathname === "/send") { + if (!senderAddress || !inboxAddress) { + return yield* HttpServerResponse.json( + { + ok: false, + message: + "CLOUDFLARE_TEST_EMAIL_FROM and CLOUDFLARE_TEST_EMAIL_INBOX are required", + }, + { status: 400 }, + ); + } + const subject = + url.searchParams.get("subject") ?? + `alchemy email test ${Date.now()}`; + const result = yield* sender + .send({ + from: senderAddress, + to: inboxAddress, + subject, + text: `sent at ${new Date().toISOString()}`, + }) + .pipe( + Effect.match({ + onSuccess: () => ({ ok: true as const, subject }), + onFailure: (err) => ({ + ok: false as const, + message: err.message, + }), + }), + ); + return yield* HttpServerResponse.json(result); + } + + return HttpServerResponse.text("Not Found", { status: 404 }); + }), + }; + }).pipe( + Effect.provide(Cloudflare.EmailEventSourceLive), + Effect.provide(Cloudflare.Email.SendBinding), + ), +) {} diff --git a/website/src/content/docs/cloudflare/email/email-worker.mdx b/website/src/content/docs/cloudflare/email/email-worker.mdx new file mode 100644 index 0000000000..87a329b82f --- /dev/null +++ b/website/src/content/docs/cloudflare/email/email-worker.mdx @@ -0,0 +1,161 @@ +--- +title: Receive email in a Worker +description: Receive inbound mail in a Cloudflare Worker with Cloudflare.email({ zone }).subscribe(...) — auto-creates routing and forwarding rules. +sidebar: + order: 20 +--- + +`Cloudflare.email({ zone }).subscribe(handler)` is the +Effect-native API for Cloudflare Email Workers. One call wires +both halves of the consumer: + +- **Runtime**: registers the `email` event listener on the Worker. +- **Deploy-time**: yields an `Email.Routing` toggle on the zone and + an `Email.Rule` whose `actions: [{ type: "worker", … }]` targets + this Worker. + +Unlike queues there is no batch or stream — the handler runs once +per message and returns `Effect`. + +For the rest of the Email surface — verifying destination +addresses, forwarding rules, the catch-all, sending from a Worker — +see [Send & receive email](/cloudflare/email/send-and-receive). + +## Subscribe to inbound mail + +The handler receives a `ForwardableEmailMessage` whose action +methods (`forward`, `reply`, `setReject`) return `Effect`s rather +than `Promise`s, so they compose with the rest of your effect +program. + +```typescript +// src/Inbox.ts +import * as Cloudflare from "alchemy/Cloudflare"; +import * as Effect from "effect/Effect"; + +export default Cloudflare.Worker( + "Inbox", + { main: import.meta.url }, + Effect.gen(function* () { + yield* Cloudflare.email({ zone: "example.com" }).subscribe((message) => + message.forward("ops@example.com"), + ); + return {}; + }).pipe(Effect.provide(Cloudflare.EmailEventSourceLive)), +); +``` + +That's it — `alchemy.run.ts` doesn't need any extra `Email.Routing` +or `Email.Rule` calls; the event source yields them as siblings of +the Worker. + +## Provide the runtime layer + +`subscribe(...)` is a `Context.Service` call — +`EmailEventSourceLive` is the layer that wires the listener into +the Worker's runtime dispatch. + +```diff lang="typescript" + Effect.gen(function* () { + yield* Cloudflare.email({ zone: "example.com" }).subscribe(...); + return {}; + }).pipe( ++ Effect.provide(Cloudflare.EmailEventSourceLive), + ), +``` + +Without the live layer, the subscribe call fails at deploy with +`Service not found: Cloudflare.Workers.EmailEventSource`. + +## Forward a message + +`message.forward(address)` hands the message off to a verified +destination. The destination address must already exist on the +account — declare it with `Email.Address` so Cloudflare sends the +verification mail during deploy. + +```typescript +const ops = yield* Cloudflare.Email.Address("Ops", { + email: "ops@example.com", +}); + +yield* Cloudflare.email({ zone: "example.com" }).subscribe((message) => + message.forward(ops.email), +); +``` + +`forward` (and `reply`) fail with `EmailError` if Cloudflare +rejects the action (e.g. an unverified destination); `setReject` +never fails. + +## Reject a message + +`message.setReject(reason)` bounces the message back to the +sender. Use it for closed mailboxes or anti-abuse checks. + +```typescript +yield* Cloudflare.email({ zone: "example.com" }).subscribe((message) => + message.headers.get("x-spam-score") === "high" + ? message.setReject("Rejected: spam") + : message.forward("ops@example.com"), +); +``` + +## Match a specific address + +By default `email({ zone })` installs a catch-all rule. Pass +`matchers` to scope the rule to specific recipients: + +```typescript +yield* Cloudflare.email({ + zone: "example.com", + matchers: [{ type: "literal", field: "to", value: "hello@example.com" }], +}).subscribe((message) => message.forward("ops@example.com")); +``` + +## Use the raw Cloudflare message + +The wrapped message exposes `raw` as an escape hatch to the +underlying `cf.ForwardableEmailMessage`. Useful for fields not yet +surfaced on the wrapper, or for SDKs that want the native type. + +```typescript +yield* Cloudflare.email({ zone: "example.com" }).subscribe((message) => + Effect.log(`raw size: ${message.raw.rawSize}`), +); +``` + +## Bring your own routing + +If you're already managing `Email.Routing` / `Email.Rule` resources +in `alchemy.run.ts` and just want the runtime listener, call +`email()` with no `zone` — the deploy-time half becomes a no-op. + +```typescript +yield* Cloudflare.email().subscribe((message) => + Effect.log(`from ${message.from}`), +); +``` + +## What's the difference vs. a native `email()` handler? + +Cloudflare's runtime delivers email events to an +`email(message, env, ctx)` export on the Worker module. You can +write that directly: + +```typescript +export default { + async email(message, env, ctx) { + await message.forward("ops@example.com"); + }, +}; +``` + +`Cloudflare.email({ zone }).subscribe(...)` is the same primitive +on the Effect side — the Worker bundle's runtime dispatch routes +the `email` event to the registered listener — but you also get +`Effect.gen` composition, typed errors, the auto-created +`Email.Routing` + `Email.Rule`, and the same shape as the other +Cloudflare event sources +([`Cloudflare.Workers.cron`](/cloudflare/messaging/cron), +[`Cloudflare.Queues.consumeQueueMessages`](/cloudflare/messaging/queues)).