1import * as Cloudflare from "@/Cloudflare/index.ts";
2import * as Config from "effect/Config";
3import * as Effect from "effect/Effect";
4import { HttpServerRequest } from "effect/http/HttpServerRequest";
5import * as HttpServerResponse from "effect/http/HttpServerResponse";
6
7/**
8 * Worker-runtime config loaded via Effect's `Config`. Captured during the
9 * Init phase below, which makes Alchemy bind them as `plain_text` on the
10 * Worker — at runtime the same `Config` calls re-resolve from those
11 * bindings via the runtime `ConfigProvider`.
12 *
13 * The zone and inbox default to the standing test zone so the deploy-time
14 * half of the event source (the auto-created `Email.Routing` +
15 * `Email.Rule`) is always exercised. Only the outbound sender is optional —
16 * the send→receive round trip needs a verified destination, which not every
17 * account has.
18 */
19const ZoneConfig = Config.String("CLOUDFLARE_TEST_DNS_ZONE_NAME").pipe(
20 Config.withDefault("alchemy-test-2.us"),
21);
22const InboxConfig = Config.String("CLOUDFLARE_TEST_EMAIL_INBOX").pipe(
23 Config.withDefault(""),
24);
25const SenderConfig = Config.String("CLOUDFLARE_TEST_EMAIL_FROM").pipe(
26 Config.withDefault(""),
27);
28
29interface ReceivedMessage {
30 from: string;
31 to: string;
32 subject: string | null;
33 bodySize: number;
34 receivedAt: number;
35}
36
37/**
38 * Durable Object that records every message the worker's email handler
39 * sees. The test polls `snapshot()` via `GET /received` to confirm the
40 * inbound dispatch actually fired.
41 */
42export class Inbox extends Cloudflare.DurableObject<Inbox>()(
43 "Inbox",
44 Effect.gen(function* () {
45 return Effect.gen(function* () {
46 const state = yield* Cloudflare.DurableObjectState;
47 let received =
48 (yield* state.storage.get<ReceivedMessage[]>("received")) ?? [];
49 return {
50 record: Effect.fn(function* (msg: ReceivedMessage) {
51 received = [...received, msg];
52 yield* state.storage.put("received", received);
53 }),
54 snapshot: () => Effect.succeed({ received }),
55 reset: Effect.fn(function* () {
56 received = [];
57 yield* state.storage.put("received", received);
58 }),
59 };
60 });
61 }),
62) {}
63
64/**
65 * Fixture worker for `EmailEventSource.test.ts`.
66 *
67 * Wires `Cloudflare.email({ zone, matchers }).subscribe(...)` to record
68 * each inbound message on an `Inbox` DO, and exposes:
69 *
70 * - `POST /send` — emits an outbound message via the `send_email` binding
71 * to the address the worker also subscribes to.
72 * - `GET /received` — snapshot of recorded inbound messages.
73 * - `POST /reset` — clear the DO state.
74 * - `GET /config` — the zone/inbox the subscribe was wired to, so the
75 * test can assert against the same values the Worker resolved.
76 *
77 * The deploy-time policy auto-creates `Email.Routing` + `Email.Rule` so no
78 * routing wiring is needed in the test stack.
79 */
80export default class EmailTestWorker extends Cloudflare.Worker<EmailTestWorker>()(
81 "EmailTestWorker",
82 {
83 main: import.meta.filename,
84 workersDev: { enabled: true, previewsEnabled: false },
85 compatibility: { date: "2024-09-23", flags: ["nodejs_compat"] },
86 },
87 Effect.gen(function* () {
88 const inboxes = yield* Inbox;
89
90 // Resolve the Configs once at Init. At deploy time these come from
91 // Node's env (loaded by the Stack); Alchemy then binds the resolved
92 // values onto the Worker as plain_text so the same Config calls
93 // re-resolve from bindings at runtime.
94 const zone = yield* ZoneConfig;
95 const inboxAddress = (yield* InboxConfig) || `inbox@${zone}`;
96 const senderAddress = yield* SenderConfig;
97
98 // `send_email` binding the test worker uses to seed a message into
99 // the inbox it also subscribes to.
100 const Sender = yield* Cloudflare.Email.SendEmail("Sender", {
101 allowedSenderAddresses: senderAddress ? [senderAddress] : undefined,
102 destinationAddress: inboxAddress,
103 });
104 const sender = yield* Cloudflare.Email.Send(Sender);
105
106 // Always subscribe: this is the deploy-time half under test, and it
107 // needs no mail to flow to be verifiable.
108 yield* Cloudflare.email({
109 zone,
110 matchers: [{ type: "literal", field: "to", value: inboxAddress }],
111 }).subscribe((message) =>
112 inboxes.getByName("default").record({
113 from: message.from,
114 to: message.to,
115 subject: message.headers.get("subject"),
116 bodySize: message.bodySize,
117 receivedAt: Date.now(),
118 }),
119 );
120
121 return {
122 fetch: Effect.gen(function* () {
123 const request = yield* HttpServerRequest;
124 const url = new URL(request.url, "http://x");
125
126 if (request.method === "GET" && url.pathname === "/config") {
127 return yield* HttpServerResponse.json({ zone, inbox: inboxAddress });
128 }
129
130 if (request.method === "GET" && url.pathname === "/received") {
131 const snapshot = yield* inboxes.getByName("default").snapshot();
132 return yield* HttpServerResponse.json(snapshot);
133 }
134
135 if (request.method === "POST" && url.pathname === "/reset") {
136 yield* inboxes.getByName("default").reset();
137 return yield* HttpServerResponse.json({ ok: true });
138 }
139
140 if (request.method === "POST" && url.pathname === "/send") {
141 if (!senderAddress) {
142 return yield* HttpServerResponse.json(
143 {
144 ok: false,
145 message: "CLOUDFLARE_TEST_EMAIL_FROM is required",
146 },
147 { status: 400 },
148 );
149 }
150 const subject =
151 url.searchParams.get("subject") ??
152 `alchemy email test ${Date.now()}`;
153 const result = yield* sender
154 .send({
155 from: senderAddress,
156 to: inboxAddress,
157 subject,
158 text: `sent at ${new Date().toISOString()}`,
159 })
160 .pipe(
161 Effect.match({
162 onSuccess: () => ({ ok: true as const, subject }),
163 onFailure: (err) => ({
164 ok: false as const,
165 message: err.message,
166 }),
167 }),
168 );
169 return yield* HttpServerResponse.json(result);
170 }
171
172 return HttpServerResponse.text("Not Found", { status: 404 });
173 }),
174 };
175 }).pipe(
176 Effect.provide(Cloudflare.EmailEventSourceLive),
177 Effect.provide(Cloudflare.Email.SendBinding),
178 ),
179) {}