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 HttpClientRequest from "effect/http/HttpClientRequest";
12import * as pathe from "pathe";
13
14// `dev: true` runs local providers behind the RPC sidecar proxy by default,
15// matching the process topology of the real `alchemy dev` command (see
16// MakeOptions.sidecar in Test/Core.ts).
17const { test } = Test.make({
18 providers: Cloudflare.providers(),
19 dev: true,
20});
21
22const logLevel = Effect.provideService(
23 MinimumLogLevel,
24 process.env.DEBUG ? "Debug" : "Info",
25);
26
27class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{
28 status: number;
29}> {}
30
31const getReady = (url: string) =>
32 Effect.gen(function* () {
33 const client = yield* HttpClient.HttpClient;
34 return yield* client.get(url).pipe(
35 Effect.flatMap((res) =>
36 res.status === 200
37 ? Effect.succeed(res)
38 : Effect.fail(new WorkerNotReady({ status: res.status })),
39 ),
40 Effect.retry({
41 while: (e): e is WorkerNotReady => e instanceof WorkerNotReady,
42 // Cap the backoff: an uncapped exponential over 10 recurs sums to
43 // ~8.5 minutes and turns a persistent non-200 into an apparent hang.
44 schedule: Schedule.max([
45 Schedule.min([
46 Schedule.exponential("500 millis"),
47 Schedule.spaced("2 seconds"),
48 ]),
49 Schedule.recurs(10),
50 ]),
51 }),
52 );
53 }).pipe(Effect.orDie);
54
55// -----------------------------------------------------------------------------
56// MIME fixtures — hard-coded constants mirroring the runtime's own inbound
57// email tests (\n line endings; the parser accepts both \n and \r\n).
58// -----------------------------------------------------------------------------
59
60const FROM = "someone@example.com";
61const TO = "someone-else@example.com";
62
63const emailFixture = (headers: Array<string>) =>
64 [
65 ...headers,
66 "MIME-Version: 1.0",
67 "Content-Type: text/plain",
68 "",
69 "This is a random email body.",
70 ].join("\n");
71
72const incomingEmail = (subject: string, messageId: string) =>
73 emailFixture([
74 `From: someone <${FROM}>`,
75 `To: someone else <${TO}>`,
76 `Subject: ${subject}`,
77 `Message-ID: ${messageId}`,
78 ]);
79
80const emailTriggerPath = (workerUrl: string) =>
81 `${workerUrl}/cdn-cgi/handler/email?from=${encodeURIComponent(FROM)}&to=${encodeURIComponent(TO)}`;
82
83/** POST a raw MIME message to the worker's inbound email trigger route. */
84const postEmail = (workerUrl: string, raw: string) =>
85 Effect.gen(function* () {
86 const client = yield* HttpClient.HttpClient;
87 return yield* client.execute(
88 HttpClientRequest.post(emailTriggerPath(workerUrl)).pipe(
89 HttpClientRequest.bodyText(raw),
90 ),
91 );
92 });
93
94interface ReceivedEmail {
95 from: string;
96 to: string;
97 rawSize: number;
98 raw: string;
99 subject: string;
100 messageIdHeader: string | null;
101}
102
103class EmlNotFound extends Data.TaggedError("EmlNotFound")<{
104 directory: string;
105}> {}
106
107/**
108 * Find the persisted reply `.eml` under the local runtime storage directory
109 * (`.alchemy/local/email`) whose content carries `marker`. Bounded poll: the
110 * write happens before `reply()` resolves, but the simulator runs in the
111 * sidecar process, so allow a few refresh rounds.
112 */
113const findEmlWithMarker = (marker: string) =>
114 Effect.gen(function* () {
115 const fs = yield* FileSystem.FileSystem;
116 const path = yield* Path.Path;
117 const cwd = yield* Effect.sync(() => process.cwd());
118 const emailDir = path.join(cwd, ".alchemy", "local", "email");
119 const files = yield* fs
120 .readDirectory(emailDir)
121 .pipe(Effect.orElseSucceed(() => [] as Array<string>));
122 for (const name of files) {
123 if (!name.endsWith(".eml")) continue;
124 const content = yield* fs
125 .readFileString(path.join(emailDir, name))
126 .pipe(Effect.orElseSucceed(() => ""));
127 if (content.includes(`reply-marker:${marker}`)) {
128 return content;
129 }
130 }
131 return yield* new EmlNotFound({ directory: emailDir });
132 }).pipe(
133 Effect.retry({
134 while: (e): e is EmlNotFound => e instanceof EmlNotFound,
135 schedule: Schedule.spaced("500 millis"),
136 times: 10,
137 }),
138 );
139
140/**
141 * Under `alchemy dev` the local runtime dispatches
142 * `POST {workerUrl}/cdn-cgi/handler/email?from=X&to=Y` (raw MIME body) to
143 * the worker's `email()` handler — mirroring Miniflare's inbound email
144 * trigger route. This exercises the full local roundtrip: dispatch (200),
145 * `setReject` (400 with the reason), and `message.reply(...)` persisting the
146 * reply `.eml` under the local storage's `email/` dir.
147 */
148test.provider(
149 "inbound email trigger dispatches to the worker's email() handler",
150 (stack) =>
151 Effect.gen(function* () {
152 yield* stack.destroy();
153
154 const deployed = yield* stack.deploy(
155 Effect.gen(function* () {
156 const kv = yield* Cloudflare.KV.Namespace("EmailHandlerKV");
157 const worker = yield* Cloudflare.Worker("email-handler-worker", {
158 main: pathe.resolve(
159 import.meta.dirname,
160 "fixtures/email-handler-worker.ts",
161 ),
162 env: { KV: kv },
163 });
164 return { worker };
165 }),
166 );
167
168 // The worker serves from the local dev proxy — proof no deployment to
169 // Cloudflare happened.
170 expect(deployed.worker.url).toMatch(/^http:\/\/localhost:\d+$/);
171
172 // Wait for the local worker to serve before driving the trigger route.
173 yield* getReady(deployed.worker.url!);
174
175 const marker = yield* Effect.sync(() => crypto.randomUUID());
176
177 // 1. Accepted email → 200, and the handler observed the envelope
178 // addresses, the raw MIME content, and the parsed headers.
179 const okRaw = incomingEmail(
180 `accept-me:${marker}`,
181 `<incoming-${marker}@example.com>`,
182 );
183 const okRes = yield* postEmail(deployed.worker.url!, okRaw);
184 expect(okRes.status).toBe(200);
185 expect(yield* okRes.text).toBe("Worker successfully processed email");
186
187 const received = (yield* (yield* getReady(
188 `${deployed.worker.url}/received`,
189 )).json) as unknown as Array<ReceivedEmail>;
190 expect(received).toHaveLength(1);
191 const message = received[0];
192 // Envelope addresses come from the URL parameters, the raw MIME body
193 // and parsed headers from the request body.
194 expect(message.from).toBe(FROM);
195 expect(message.to).toBe(TO);
196 expect(message.subject).toBe(`accept-me:${marker}`);
197 expect(message.raw).toBe(okRaw);
198 expect(message.rawSize).toBe(new TextEncoder().encode(okRaw).byteLength);
199 expect(message.messageIdHeader).toBe(`<incoming-${marker}@example.com>`);
200
201 // 2. `setReject(reason)` → 400 carrying the reject reason.
202 const rejectRes = yield* postEmail(
203 deployed.worker.url!,
204 incomingEmail("reject-me", `<reject-${marker}@example.com>`),
205 );
206 expect(rejectRes.status).toBe(400);
207 expect(yield* rejectRes.text).toBe(
208 "Worker rejected email with the following reason: I don't like this email",
209 );
210
211 // 3. `message.reply(new EmailMessage(...))` → 200, and the reply `.eml`
212 // is persisted under the local storage's `email/` dir with the
213 // generated `References` header pointing at the incoming message.
214 const replyRes = yield* postEmail(
215 deployed.worker.url!,
216 incomingEmail(`reply-me:${marker}`, `<parent-${marker}@example.com>`),
217 );
218 expect(replyRes.status).toBe(200);
219 expect(yield* replyRes.text).toBe("Worker successfully processed email");
220
221 const eml = yield* findEmlWithMarker(marker);
222 // The reply had no References header, so the runtime prepends one
223 // referencing the incoming message before persisting.
224 expect(eml).toContain(`References: <parent-${marker}@example.com>`);
225 expect(eml).toContain(`In-Reply-To: <parent-${marker}@example.com>`);
226 expect(eml).toContain(`Message-ID: <reply-${marker}@example.com>`);
227
228 yield* stack.destroy();
229 }).pipe(logLevel),
230 {
231 tags: [
232 "provider:cloudflare",
233 "provider:cloudflare:email",
234 "provider:cloudflare:kv",
235 "provider:cloudflare:worker",
236 "local",
237 ],
238 timeout: 180_000,
239 },
240);