| 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 pathe from "pathe"; |
| 12 | import LocalSendEmailWorker from "./fixtures/local-worker.ts"; |
| 13 | import 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). |
| 18 | const { test } = Test.make({ |
| 19 | providers: Cloudflare.providers(), |
| 20 | dev: true, |
| 21 | }); |
| 22 | |
| 23 | const logLevel = Effect.provideService( |
| 24 | MinimumLogLevel, |
| 25 | process.env.DEBUG ? "Debug" : "Info", |
| 26 | ); |
| 27 | |
| 28 | class WorkerNotReady extends Data.TaggedError("WorkerNotReady")<{ |
| 29 | status: number; |
| 30 | }> {} |
| 31 | |
| 32 | const 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 | |
| 54 | class 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 | */ |
| 64 | const 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 | */ |
| 98 | test.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 | */ |
| 194 | test.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 | |
| 243 | const FROM = process.env.CLOUDFLARE_TEST_EMAIL_FROM; |
| 244 | const TO = process.env.CLOUDFLARE_TEST_EMAIL_TO; |
| 245 | const 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 | */ |
| 255 | test.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 | ); |