| 1 | import * as Cloudflare from "@/Cloudflare/index.ts"; |
| 2 | import * as Test from "@/Test/Alchemy"; |
| 3 | import { expect } from "alchemy-test"; |
| 4 | import * as Data from "effect/Data"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as FileSystem from "effect/FileSystem"; |
| 7 | import * as Path from "effect/Path"; |
| 8 | import { MinimumLogLevel } from "effect/References"; |
| 9 | import * as Schedule from "effect/Schedule"; |
| 10 | import * as HttpClient from "effect/http/HttpClient"; |
| 11 | import * as HttpClientRequest from "effect/http/HttpClientRequest"; |
| 12 | import * 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). |
| 17 | const { test } = Test.make({ |
| 18 | providers: Cloudflare.providers(), |
| 19 | dev: true, |
| 20 | }); |
| 21 | |
| 22 | const logLevel = Effect.provideService( |
| 23 | MinimumLogLevel, |
| 24 | process.env.DEBUG ? "Debug" : "Info", |
| 25 | ); |
| 26 | |
| 27 | class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{ |
| 28 | status: number; |
| 29 | }> {} |
| 30 | |
| 31 | const 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 | |
| 60 | const FROM = "someone@example.com"; |
| 61 | const TO = "someone-else@example.com"; |
| 62 | |
| 63 | const 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 | |
| 72 | const 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 | |
| 80 | const 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. */ |
| 84 | const 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 | |
| 94 | interface ReceivedEmail { |
| 95 | from: string; |
| 96 | to: string; |
| 97 | rawSize: number; |
| 98 | raw: string; |
| 99 | subject: string; |
| 100 | messageIdHeader: string | null; |
| 101 | } |
| 102 | |
| 103 | class 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 | */ |
| 113 | const 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 | */ |
| 148 | test.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 | ); |