1import * as Cloudflare from "alchemy/Cloudflare";
2import * as Test from "alchemy/Test/Bun";
3import { expect } from "bun:test";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as HttpBody from "effect/http/HttpBody";
7import * as HttpClient from "effect/http/HttpClient";
8import * as HttpClientRequest from "effect/http/HttpClientRequest";
9import Stack from "../alchemy.run.ts";
10
11const { test, beforeAll, afterAll, deploy, destroy } = Test.make({
12 providers: Cloudflare.providers(),
13 state: Cloudflare.state(),
14});
15
16const stack = beforeAll(deploy(Stack));
17
18afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack));
19
20// workers.dev URLs take a few seconds to propagate after first enable.
21const getOnce = (url: string) =>
22 Effect.gen(function* () {
23 const response = yield* HttpClient.get(url);
24 return response;
25 }).pipe(Effect.retry({ schedule: Schedule.spaced("1 second"), times: 30 }));
26
27test(
28 "stack outputs reflect the deployed email infrastructure",
29 Effect.gen(function* () {
30 const out = yield* stack;
31 expect(out.url).toBeString();
32 expect(out.destinationEmail).toBeString();
33 expect(out.inbox).toBeString();
34 }),
35);
36
37test(
38 "worker exposes the configured sender/destination on /healthz",
39 Effect.gen(function* () {
40 const { url } = yield* stack;
41 const response = yield* getOnce(`${url.replace(/\/+$/, "")}/healthz`);
42 expect(response.status).toBe(200);
43 const body = (yield* response.json) as {
44 ok: boolean;
45 from: string;
46 to: string;
47 inbox: string;
48 };
49 expect(body.ok).toBe(true);
50 expect(body.from).toBeString();
51 expect(body.to).toBeString();
52 expect(body.inbox).toBeString();
53 }),
54 { timeout: 60_000 },
55);
56
57test(
58 "worker sends an email via the send_email binding",
59 Effect.gen(function* () {
60 const { url } = yield* stack;
61 const baseUrl = url.replace(/\/+$/, "");
62
63 yield* getOnce(baseUrl);
64
65 const response = yield* HttpClient.execute(
66 HttpClientRequest.post(`${baseUrl}/send`).pipe(
67 HttpClientRequest.setBody(
68 HttpBody.text(
69 JSON.stringify({
70 subject: `alchemy integ ${Date.now()}`,
71 text: "hello from cloudflare-email integ.test.ts",
72 }),
73 "application/json",
74 ),
75 ),
76 ),
77 );
78 expect(response.status).toBe(200);
79 const body = (yield* response.json) as {
80 ok: boolean;
81 message?: string;
82 };
83 if (!body.ok) {
84 // Surface the Cloudflare-side error so the failure is debuggable.
85 // Most often: "destination address not verified" until a human
86 // clicks the link sent by EmailAddress.
87 throw new Error(`send_email rejected the message: ${body.message}`);
88 }
89 expect(body.ok).toBe(true);
90 }),
91 { timeout: 120_000 },
92);
93
94test(
95 "worker records inbound mail via the email subscribe handler",
96 Effect.gen(function* () {
97 const { url, inbox } = yield* stack;
98 const baseUrl = url.replace(/\/+$/, "");
99
100 yield* getOnce(baseUrl);
101
102 // Clear any messages left over from a previous run.
103 yield* HttpClient.execute(HttpClientRequest.post(`${baseUrl}/reset`));
104
105 const subject = `alchemy inbound ${Date.now()}`;
106
107 // Seed a message addressed to INBOX. The Worker's `send_email`
108 // binding is pinned to DESTINATION, so we route through `/send`
109 // with an explicit `to` override targeted at INBOX, then poll the
110 // `/received` snapshot until the email handler fires (or give up).
111 const sendResponse = yield* HttpClient.execute(
112 HttpClientRequest.post(`${baseUrl}/send`).pipe(
113 HttpClientRequest.setBody(
114 HttpBody.text(
115 JSON.stringify({ subject, text: "inbound", to: inbox }),
116 "application/json",
117 ),
118 ),
119 ),
120 );
121 const sendBody = (yield* sendResponse.json) as {
122 ok: boolean;
123 message?: string;
124 };
125 if (!sendBody.ok) {
126 // Send may legitimately reject (unverified destination, sender
127 // not on the same zone as INBOX); skip the receive assertion
128 // rather than fail the whole suite on environment setup.
129 return;
130 }
131
132 const snapshot = yield* HttpClient.get(`${baseUrl}/received`).pipe(
133 Effect.flatMap((res) => res.json),
134 Effect.map(
135 (b) =>
136 b as {
137 received: Array<{
138 from: string;
139 to: string;
140 subject: string | null;
141 bodySize: number;
142 }>;
143 },
144 ),
145 Effect.repeat({
146 schedule: Schedule.spaced("5 seconds"),
147 until: (s) => s.received.some((m) => m.subject === subject),
148 times: 24,
149 }),
150 );
151
152 expect(snapshot.received.some((m) => m.subject === subject)).toBe(true);
153 }),
154 { timeout: 180_000 },
155);