1import * as Cloudflare from "@/Cloudflare";
2import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
3import { findZoneByName } from "@/Cloudflare/Zone/lookup";
4import * as Alchemy from "@/index.ts";
5import * as Test from "@/Test/Alchemy";
6import * as emailRouting from "@distilled.cloud/cloudflare/email-routing";
7import { expect } from "alchemy-test";
8import * as Effect from "effect/Effect";
9import { MinimumLogLevel } from "effect/References";
10import * as Schedule from "effect/Schedule";
11import * as HttpClient from "effect/http/HttpClient";
12import EmailCatchAllWorker from "./fixtures/email-catchall-worker.ts";
13import EmailTestWorker from "./fixtures/email-worker.ts";
14
15const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
16 providers: Cloudflare.providers(),
17 state: Cloudflare.state(),
18});
19
20const logLevel = Effect.provideService(
21 MinimumLogLevel,
22 process.env.DEBUG ? "Debug" : "Info",
23);
24
25const ZONE = process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us";
26const INBOX = process.env.CLOUDFLARE_TEST_EMAIL_INBOX || `inbox@${ZONE}`;
27// The send→receive round trip additionally needs an outbound sender and a
28// destination Cloudflare has verified; the deploy-time assertions below do
29// not, so only the round trip is gated.
30const skipRoundTrip = !process.env.CLOUDFLARE_TEST_EMAIL_FROM;
31
32const Stack = Alchemy.Stack(
33 "EmailEventSourceStack",
34 {
35 providers: Cloudflare.providers(),
36 state: Cloudflare.state(),
37 },
38 Effect.gen(function* () {
39 const worker = yield* EmailTestWorker;
40 return {
41 url: worker.url.as<string>(),
42 workerName: worker.workerName,
43 };
44 }),
45);
46
47const stack = beforeAll(deploy(Stack));
48afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack));
49
50// The scoped API token the test harness mints propagates eventually-
51// consistently across Cloudflare's edge — a fresh token intermittently 403s.
52const rideOutAuth = <A, E extends { _tag: string }, R>(
53 effect: Effect.Effect<A, E, R>,
54) =>
55 effect.pipe(
56 Effect.retry({
57 while: (e) => e._tag === "Forbidden" || e._tag === "Unauthorized",
58 schedule: Schedule.exponential("500 millis"),
59 times: 8,
60 }),
61 );
62
63const resolveZoneId = Effect.gen(function* () {
64 const { accountId } = yield* yield* CloudflareEnvironment;
65 const zone = yield* findZoneByName({ accountId, name: ZONE });
66 if (!zone) {
67 return yield* Effect.die(new Error(`zone "${ZONE}" not found in account`));
68 }
69 return zone.id;
70});
71
72// The deploy-time half of the event source: `email({ zone, matchers })`
73// yields an `Email.Routing` toggle and an `Email.Rule` whose action targets
74// the host Worker. Neither needs mail to flow, so this runs everywhere.
75test.provider(
76 "subscribe auto-creates Email.Routing and an Email.Rule targeting the worker",
77 () =>
78 Effect.gen(function* () {
79 const { workerName } = yield* stack;
80 const zoneId = yield* resolveZoneId;
81
82 const routing = yield* rideOutAuth(
83 emailRouting.getEmailRouting({ zoneId }),
84 );
85 expect(routing.enabled).toBe(true);
86
87 const rules = yield* rideOutAuth(emailRouting.listRules({ zoneId }));
88 const ours = (rules.result ?? []).find((rule) =>
89 (rule.actions ?? []).some(
90 (a) => a.type === "worker" && (a.value ?? []).includes(workerName),
91 ),
92 );
93 expect(ours).toBeDefined();
94 expect(ours!.enabled).toBe(true);
95 expect(ours!.matchers).toEqual([
96 { type: "literal", field: "to", value: INBOX },
97 ]);
98 }).pipe(logLevel),
99 {
100 tags: [
101 "provider:cloudflare",
102 "provider:cloudflare:email",
103 "provider:cloudflare:worker",
104 "provider:cloudflare:zone",
105 "live",
106 ],
107 timeout: 180_000,
108 },
109);
110
111test.skipIf(skipRoundTrip)(
112 "deployed worker receives inbound mail routed by the auto-created Email.Rule",
113 Effect.gen(function* () {
114 const { url } = yield* stack;
115 const client = yield* HttpClient.HttpClient;
116
117 // Reset DO state and double as a readiness probe — fresh workers.dev
118 // URLs take a few seconds to start serving 200s.
119 yield* Effect.gen(function* () {
120 const res = yield* client.post(`${url}/reset`);
121 if (res.status !== 200) {
122 return yield* Effect.fail(new Error(`Worker not ready: ${res.status}`));
123 }
124 }).pipe(
125 Effect.retry({
126 schedule: Schedule.exponential("500 millis"),
127 times: 10,
128 }),
129 );
130 const resetAt = Date.now();
131
132 // Send a unique-subject message via the worker's send_email binding.
133 const subject = `alchemy email test ${resetAt}`;
134 const sendUrl = `${url}/send?subject=${encodeURIComponent(subject)}`;
135 yield* Effect.gen(function* () {
136 const res = yield* client.post(sendUrl);
137 if (res.status !== 200) {
138 return yield* Effect.fail(new Error(`/send failed: ${res.status}`));
139 }
140 const body = (yield* res.json) as { ok: boolean; message?: string };
141 if (!body.ok) {
142 return yield* Effect.fail(
143 new Error(`send_email failed: ${body.message}`),
144 );
145 }
146 }).pipe(
147 Effect.retry({
148 schedule: Schedule.exponential("500 millis"),
149 times: 5,
150 }),
151 );
152
153 // Cloudflare's email pipeline is async — the inbound dispatch typically
154 // lands within ~30s but can take longer under load.
155 const received = yield* Effect.gen(function* () {
156 const res = yield* client.get(`${url}/received`);
157 if (res.status !== 200) return [];
158 const body = (yield* res.json) as { received?: unknown };
159 if (!Array.isArray(body.received)) return [];
160 return body.received.filter(
161 (r): r is { subject: string | null; receivedAt: number } =>
162 typeof r === "object" &&
163 r !== null &&
164 (r as { subject?: unknown }).subject === subject,
165 );
166 }).pipe(
167 Effect.catch(() => Effect.succeed([])),
168 Effect.repeat({
169 schedule: Schedule.spaced("5 seconds"),
170 until: (matches) => matches.length > 0,
171 times: 48,
172 }),
173 );
174
175 expect(received.length).toBeGreaterThan(0);
176 for (const msg of received) {
177 expect(msg.subject).toBe(subject);
178 expect(msg.receivedAt).toBeGreaterThanOrEqual(resetAt);
179 }
180 }).pipe(logLevel),
181 {
182 tags: [
183 "provider:cloudflare",
184 "provider:cloudflare:email",
185 "provider:cloudflare:worker",
186 "live",
187 ],
188 timeout: 360_000,
189 },
190);
191
192// The catch-all form: `email({ zone })` with no matchers. Cloudflare
193// rejects a lone `{ type: "all" }` matcher on `createRule` with
194// "Invalid rule operation", so this stack only deploys at all if the
195// event source provisions `Email.CatchAll` instead of `Email.Rule`.
196const CatchAllStack = Alchemy.Stack(
197 "EmailEventSourceCatchAllStack",
198 {
199 providers: Cloudflare.providers(),
200 state: Cloudflare.state(),
201 },
202 Effect.gen(function* () {
203 const worker = yield* EmailCatchAllWorker;
204 return { workerName: worker.workerName };
205 }),
206);
207
208const catchAllStack = beforeAll(deploy(CatchAllStack));
209afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(CatchAllStack));
210
211test.provider(
212 "a catch-all subscription provisions Email.CatchAll, not an Email.Rule",
213 () =>
214 Effect.gen(function* () {
215 const { workerName } = yield* catchAllStack;
216 const zoneId = yield* resolveZoneId;
217
218 // The zone's catch-all singleton now points at the worker.
219 const catchAll = yield* rideOutAuth(
220 emailRouting.getRuleCatchAll({ zoneId }),
221 );
222 expect(catchAll.enabled).toBe(true);
223 expect(catchAll.actions?.[0]?.type).toEqual("worker");
224 expect(catchAll.actions?.[0]?.value).toEqual([workerName]);
225
226 // ...and no ordinary rule was created for it. `listRules` surfaces
227 // the catch-all too, so exclude it by id rather than by matcher.
228 const rules = yield* rideOutAuth(emailRouting.listRules({ zoneId }));
229 const strays = (rules.result ?? [])
230 .filter((r) => r.id !== catchAll.id)
231 .filter((r) =>
232 (r.actions ?? []).some(
233 (a) => a.type === "worker" && (a.value ?? []).includes(workerName),
234 ),
235 );
236 expect(strays).toEqual([]);
237 }).pipe(logLevel),
238 {
239 tags: [
240 "provider:cloudflare",
241 "provider:cloudflare:email",
242 "provider:cloudflare:worker",
243 "provider:cloudflare:zone",
244 "live",
245 ],
246 timeout: 180_000,
247 },
248);