1import * as Cloudflare from "@/Cloudflare/index.ts";
2import * as Test from "@/Test/Alchemy";
3import { expect } from "alchemy-test";
4import * as Data from "effect/Data";
5import * as Effect from "effect/Effect";
6import * as FileSystem from "effect/FileSystem";
7import * as Path from "effect/Path";
8import { MinimumLogLevel } from "effect/References";
9import * as Schedule from "effect/Schedule";
10import * as HttpClient from "effect/http/HttpClient";
11import * as pathe from "pathe";
12import LocalSendEmailWorker from "./fixtures/local-worker.ts";
13import RemoteEmailWorker from "./fixtures/remote-email-worker.ts";
14
15// `dev: true` runs local providers behind the RPC sidecar proxy by default,
16// matching the process topology of the real `alchemy dev` command (see
17// MakeOptions.sidecar in Test/Core.ts).
18const { test } = Test.make({
19 providers: Cloudflare.providers(),
20 dev: true,
21});
22
23const logLevel = Effect.provideService(
24 MinimumLogLevel,
25 process.env.DEBUG ? "Debug" : "Info",
26);
27
28class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{
29 status: number;
30}> {}
31
32const getReady = (url: string, expected: Array<number>) =>
33 Effect.gen(function* () {
34 const client = yield* HttpClient.HttpClient;
35 return yield* client.get(url).pipe(
36 Effect.flatMap((res) =>
37 expected.includes(res.status)
38 ? Effect.succeed(res)
39 : Effect.fail(new WorkerNotReady({ status: res.status })),
40 ),
41 Effect.retry({
42 while: (e): e is WorkerNotReady => e instanceof WorkerNotReady,
43 schedule: Schedule.max([
44 Schedule.min([
45 Schedule.exponential("500 millis"),
46 Schedule.spaced("2 seconds"),
47 ]),
48 Schedule.recurs(10),
49 ]),
50 }),
51 );
52 }).pipe(Effect.orDie);
53
54class EmlNotFound extends Data.TaggedError("EmlNotFound")<{
55 directory: string;
56}> {}
57
58/**
59 * Find the persisted `.eml` under the local runtime storage directory
60 * (`.alchemy/local/email`) whose content carries `marker`. Bounded poll: the
61 * write happens before `send()` resolves, but the simulator runs in the
62 * sidecar process, so allow a few refresh rounds.
63 */
64const findEmlWithMarker = (marker: string) =>
65 Effect.gen(function* () {
66 const fs = yield* FileSystem.FileSystem;
67 const path = yield* Path.Path;
68 const cwd = yield* Effect.sync(() => process.cwd());
69 const emailDir = path.join(cwd, ".alchemy", "local", "email");
70 const files = yield* fs
71 .readDirectory(emailDir)
72 .pipe(Effect.orElseSucceed(() => [] as Array<string>));
73 for (const name of files) {
74 if (!name.endsWith(".eml")) continue;
75 const content = yield* fs
76 .readFileString(path.join(emailDir, name))
77 .pipe(Effect.orElseSucceed(() => ""));
78 if (content.includes(`marker:${marker}`)) {
79 return content;
80 }
81 }
82 return yield* new EmlNotFound({ directory: emailDir });
83 }).pipe(
84 Effect.retry({
85 while: (e): e is EmlNotFound => e instanceof EmlNotFound,
86 schedule: Schedule.spaced("500 millis"),
87 times: 10,
88 }),
89 );
90
91/**
92 * Under `alchemy dev` the worker's `send_email` binding is lowered onto the
93 * local simulator: `send()` validates the message and persists it as a
94 * `.eml` under `.alchemy/local/email` instead of delivering real mail. This
95 * exercises the full local roundtrip (EmailMessage + MessageBuilder APIs)
96 * and the address validation surfacing to the caller.
97 */
98test.provider(
99 "send_email binding delivers to the local simulator",
100 (stack) =>
101 Effect.gen(function* () {
102 yield* stack.destroy();
103
104 const deployed = yield* stack.deploy(
105 Effect.gen(function* () {
106 const email = yield* Cloudflare.Email.SendEmail("LocalEmail", {
107 allowedDestinationAddresses: ["allowed@example.com"],
108 });
109 const worker = yield* Cloudflare.Worker("send-email-local-worker", {
110 main: pathe.resolve(
111 import.meta.dirname,
112 "fixtures/local-email-worker.ts",
113 ),
114 env: { EMAIL: email },
115 });
116 return { worker };
117 }),
118 );
119
120 // The worker serves from the local dev proxy — proof no deployment to
121 // Cloudflare happened (send_email itself is a Worker-only descriptor;
122 // there is no cloud resource to check).
123 expect(deployed.worker.url).toMatch(/^http:\/\/localhost:\d+$/);
124
125 // EmailMessage API: the message is validated and persisted as .eml.
126 const marker = yield* Effect.sync(() => crypto.randomUUID());
127 const rawRes = yield* getReady(
128 `${deployed.worker.url}/send-raw?marker=${marker}`,
129 [200],
130 );
131 const rawBody = (yield* rawRes.json) as {
132 ok: boolean;
133 messageId: string;
134 };
135 expect(rawBody.ok).toBe(true);
136 // The simulator synthesizes a production-shaped Message-ID.
137 expect(rawBody.messageId).toMatch(/^<[A-Za-z0-9]{36}@example\.com>$/);
138
139 // The .eml landed in the local storage directory with the full MIME
140 // message.
141 const eml = yield* findEmlWithMarker(marker);
142 expect(eml).toContain("From: sender <sender@example.com>");
143 expect(eml).toContain(`Message-ID: <${marker}@example.com>`);
144
145 // MessageBuilder API round-trips too.
146 const builderRes = yield* getReady(
147 `${deployed.worker.url}/send-builder`,
148 [200],
149 );
150 const builderBody = (yield* builderRes.json) as {
151 ok: boolean;
152 messageId: string;
153 };
154 expect(builderBody.ok).toBe(true);
155 expect(builderBody.messageId).toMatch(/^<[A-Za-z0-9]{36}@example\.com>$/);
156
157 // A disallowed destination surfaces the simulator's validation error
158 // to the caller.
159 const blockedRes = yield* getReady(
160 `${deployed.worker.url}/send-builder?to=blocked@example.com`,
161 [500],
162 );
163 const blockedBody = (yield* blockedRes.json) as {
164 ok: boolean;
165 message: string;
166 };
167 expect(blockedBody.ok).toBe(false);
168 expect(blockedBody.message).toContain(
169 "email to blocked@example.com not allowed",
170 );
171
172 yield* stack.destroy();
173 }).pipe(logLevel),
174 {
175 tags: [
176 "provider:cloudflare",
177 "provider:cloudflare:email",
178 "provider:cloudflare:worker",
179 "local",
180 ],
181 timeout: 120_000,
182 },
183);
184
185/**
186 * `Alchemy.remote()` on the descriptor opts a `send_email` binding OUT of
187 * local emulation, per binding: one dev worker binds a default (simulated)
188 * descriptor and a `remote()`-piped one side by side. The simulator accepts
189 * an unrestricted send from an arbitrary address, while the live Email
190 * service enforces sender/destination verification and rejects the same
191 * pair — proof the call was served by the real binding, with no mail
192 * delivered on either route.
193 */
194test.provider(
195 "Alchemy.remote() binds the live service alongside a simulated one",
196 (stack) =>
197 Effect.gen(function* () {
198 yield* stack.destroy();
199
200 const deployed = yield* stack.deploy(
201 Effect.gen(function* () {
202 const worker = yield* LocalSendEmailWorker;
203 return { url: worker.url.as<string>() };
204 }),
205 );
206
207 // The worker itself is served by the local dev proxy.
208 expect(deployed.url).toMatch(/^http:\/\/localhost:\d+$/);
209
210 const params =
211 "from=noreply%40alchemy-local-test.invalid&to=nobody%40example.com";
212
213 // The simulator accepts the unrestricted send without delivering.
214 const stubRes = yield* getReady(
215 `${deployed.url}/send-stub?${params}`,
216 [200],
217 );
218 const stub = (yield* stubRes.json) as { ok: boolean };
219 expect(stub.ok).toBe(true);
220
221 // The remote()-opted binding reaches the live Email service, which
222 // rejects the unverified sender/destination pair.
223 const liveRes = yield* getReady(
224 `${deployed.url}/send-live?${params}`,
225 [200],
226 );
227 const live = (yield* liveRes.json) as { ok: boolean; message?: string };
228 expect(live.ok).toBe(false);
229
230 yield* stack.destroy();
231 }).pipe(logLevel),
232 {
233 tags: [
234 "provider:cloudflare",
235 "provider:cloudflare:email",
236 "provider:cloudflare:worker",
237 "live",
238 ],
239 timeout: 120_000,
240 },
241);
242
243const FROM = process.env.CLOUDFLARE_TEST_EMAIL_FROM;
244const TO = process.env.CLOUDFLARE_TEST_EMAIL_TO;
245const skipRemote = !FROM || !TO;
246
247/**
248 * `Alchemy.remote()` opts the binding OUT of local emulation: the
249 * locally-served worker proxies `send()` through the remote-bindings preview
250 * session to the real Cloudflare Email service. Requires a verified
251 * sender/destination pair (CLOUDFLARE_TEST_EMAIL_FROM / _TO), same as the
252 * live suite — delivery itself cannot be observed out-of-band, so success of
253 * the real `send()` is the assertion.
254 */
255test.provider.skipIf(skipRemote)(
256 "Alchemy.remote() proxies send() to the real Email service",
257 (stack) =>
258 Effect.gen(function* () {
259 yield* stack.destroy();
260
261 const deployed = yield* stack.deploy(
262 Effect.gen(function* () {
263 const worker = yield* RemoteEmailWorker;
264 return { url: worker.url.as<string>() };
265 }),
266 );
267
268 // Still served locally — only the binding is remote.
269 expect(deployed.url).toMatch(/^http:\/\/localhost:\d+$/);
270
271 const sendUrl = `${deployed.url}/send?from=${encodeURIComponent(FROM!)}&to=${encodeURIComponent(TO!)}`;
272 const res = yield* getReady(sendUrl, [200]);
273 const body = (yield* res.json) as { ok: boolean; message?: string };
274 if (!body.ok) {
275 throw new Error(`remote send_email failed: ${body.message}`);
276 }
277 expect(body.ok).toBe(true);
278
279 yield* stack.destroy();
280 }).pipe(logLevel),
281 {
282 tags: [
283 "provider:cloudflare",
284 "provider:cloudflare:email",
285 "provider:cloudflare:worker",
286 "live",
287 ],
288 timeout: 180_000,
289 },
290);