Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 2 additions & 6 deletions examples/cloudflare-email/alchemy.run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";

import Api from "./src/Api.ts";
import { Destination, InboxRule, Routing } from "./src/Email.ts";
import { Destination, INBOX } from "./src/Email.ts";

export default Alchemy.Stack(
"CloudflareEmailExample",
Expand All @@ -12,18 +12,14 @@ export default Alchemy.Stack(
state: Cloudflare.state(),
},
Effect.gen(function* () {
const routing = yield* Routing;
const destination = yield* Destination;
const rule = yield* InboxRule;
const api = yield* Api;

return {
url: api.url.as<string>(),
zoneId: routing.zoneId,
routingEnabled: routing.enabled,
destinationEmail: destination.email,
destinationVerified: destination.verified,
ruleId: rule.ruleId,
inbox: INBOX,
};
}),
);
98 changes: 88 additions & 10 deletions examples/cloudflare-email/src/Api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,87 @@ import * as Cloudflare from "alchemy/Cloudflare";
import * as Effect from "effect/Effect";
import { HttpServerRequest } from "effect/unstable/http/HttpServerRequest";
import * as HttpServerResponse from "effect/unstable/http/HttpServerResponse";
import { DESTINATION, SendEmail, SENDER } from "./Email.ts";
import { DESTINATION, INBOX, SendEmail, SENDER, ZONE } from "./Email.ts";

interface ReceivedMessage {
from: string;
to: string;
subject: string | null;
bodySize: number;
receivedAt: number;
}

/**
* Minimal email service Worker.
* Durable Object that records every message the Worker's `email` handler
* sees so the integ test can confirm an inbound message round-tripped
* through Cloudflare's email routing.
*/
export class Inbox extends Cloudflare.DurableObject<Inbox>()(
"Inbox",
Effect.gen(function* () {
return Effect.gen(function* () {
const state = yield* Cloudflare.DurableObjectState;
let received =
(yield* state.storage.get<ReceivedMessage[]>("received")) ?? [];
return {
record: Effect.fn(function* (msg: ReceivedMessage) {
received = [...received, msg];
yield* state.storage.put("received", received);
}),
snapshot: () => Effect.succeed({ received }),
reset: Effect.fn(function* () {
received = [];
yield* state.storage.put("received", received);
}),
};
});
}),
) {}

/**
* Email service Worker. Demonstrates both halves of a Cloudflare Email
* Worker:
*
* - **Inbound** — `Cloudflare.email({ zone, matchers }).subscribe(...)`
* auto-creates an `EmailRouting` toggle on the zone and an `EmailRule`
* whose `actions: [{ type: "worker", … }]` targets this Worker. The
* handler records each message on the `Inbox` DO.
* - **Outbound** — the `send_email` binding from `Email.ts` lets the
* Worker emit mail from the configured sender to the verified
* destination.
*
* - `GET /healthz` — liveness probe.
* - `POST /send` body `{ subject, text }` — sends mail from the bound
* sender to the bound destination via the Worker's `send_email`
* binding. Returns `{ ok: true }` on success or `{ ok: false, message }`
* on a Cloudflare-side rejection (e.g. unverified destination).
* Routes:
*
* - `GET /healthz` — liveness probe; reports the configured addresses.
* - `POST /send` body `{ subject, text }` — sends mail from sender to
* destination via the `send_email` binding.
* - `GET /received` — snapshot of messages the `email` handler has seen.
* - `POST /reset` — clear the recorded inbox (used by the integ test).
*/
export default class Api extends Cloudflare.Worker<Api>()(
"Api",
{
main: import.meta.url,
main: import.meta.filename,
},
Effect.gen(function* () {
const email = yield* Cloudflare.Email.Send(SendEmail);
const inboxes = yield* Inbox;

// Subscribe to inbound mail addressed to INBOX on ZONE. The event
// source yields a sibling `Email.Routing` + `Email.Rule` at deploy
// time so no hand-rolled routing wiring is needed in `alchemy.run.ts`.
yield* Cloudflare.email({
zone: ZONE,
matchers: [{ type: "literal", field: "to", value: INBOX }],
}).subscribe((message) =>
inboxes.getByName("default").record({
from: message.from,
to: message.to,
subject: message.headers.get("subject"),
bodySize: message.bodySize,
receivedAt: Date.now(),
}),
);

return {
fetch: Effect.gen(function* () {
Expand All @@ -31,18 +94,30 @@ export default class Api extends Cloudflare.Worker<Api>()(
ok: true,
from: SENDER,
to: DESTINATION,
inbox: INBOX,
});
}

if (url.pathname === "/received" && request.method === "GET") {
const snapshot = yield* inboxes.getByName("default").snapshot();
return yield* HttpServerResponse.json(snapshot);
}

if (url.pathname === "/reset" && request.method === "POST") {
yield* inboxes.getByName("default").reset();
return yield* HttpServerResponse.json({ ok: true });
}

if (url.pathname === "/send" && request.method === "POST") {
const body = (yield* request.json) as {
subject?: string;
text?: string;
to?: string;
};
const result = yield* email
.send({
from: SENDER,
to: DESTINATION,
to: body.to ?? DESTINATION,
subject: body.subject ?? "alchemy email example",
text: body.text ?? `sent at ${new Date().toISOString()}`,
})
Expand All @@ -61,5 +136,8 @@ export default class Api extends Cloudflare.Worker<Api>()(
return HttpServerResponse.text("not found", { status: 404 });
}),
};
}).pipe(Effect.provide(Cloudflare.Email.SendBinding)),
}).pipe(
Effect.provide(Cloudflare.EmailEventSourceLive),
Effect.provide(Cloudflare.Email.SendBinding),
),
) {}
37 changes: 12 additions & 25 deletions examples/cloudflare-email/src/Email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,48 +8,35 @@ export const ZONE = "alchemy-test-2.us";

/**
* `from:` address the Worker is allowed to send mail as. Must live on a
* sender domain with Email Routing enabled (the `Routing` resource below
* takes care of that).
* sender domain with Email Routing enabled the `Cloudflare.email(...)`
* subscribe in `Api.ts` auto-creates the `EmailRouting` toggle for us.
*/
export const SENDER = process.env.CLOUDFLARE_EMAIL_FROM ?? `bot@${ZONE}`;

/**
* Destination the rules forward inbound mail to and that the Worker is
* pinned to send to. Cloudflare requires this address to be verified
* (recipient clicks a confirmation link) before any rule will deliver.
* Destination the Worker is pinned to send to. Cloudflare requires this
* address to be verified (recipient clicks a confirmation link) before
* `send_email` will deliver.
*/
export const DESTINATION = process.env.CLOUDFLARE_EMAIL_TO ?? "sam@alchemy.run";
export const DESTINATION =
process.env.CLOUDFLARE_EMAIL_TO ?? "michael@alchemy.run";

/**
* Enable Email Routing on the zone. This is the prerequisite for both
* receiving mail (rules) and sending mail from a Worker (`send_email`
* sender domain).
* Inbox address the Worker subscribes to. Mail addressed here is routed
* to the Worker's `email` handler via the `EmailRule` that the
* `Cloudflare.email({ zone, matchers }).subscribe(...)` call auto-creates.
*/
export const Routing = Cloudflare.Email.Routing("Routing", {
zone: ZONE,
});
export const INBOX = process.env.CLOUDFLARE_EMAIL_INBOX ?? `inbox@${ZONE}`;

/**
* Register the destination address on the account. Cloudflare emails a
* verification link the first time this address is added; until the
* recipient clicks it, rules forwarding here will silently drop.
* recipient clicks it, `send_email` calls targeting it will fail.
*/
export const Destination = Cloudflare.Email.Address("Destination", {
email: DESTINATION,
});

/**
* Forward inbound `inbox@<ZONE>` mail to the verified destination. The
* matcher is a literal `to:` match; everything else falls through to
* whatever catch-all rule (if any) is configured on the zone.
*/
export const InboxRule = Cloudflare.Email.Rule("InboxRule", {
zone: ZONE,
name: "Forward inbox to destination",
matchers: [{ type: "literal", field: "to", value: `inbox@${ZONE}` }],
actions: [{ type: "forward", value: [DESTINATION] }],
});

/**
* `send_email` Worker binding restricted to the sender/destination pair
* above so the Worker can't be tricked into emailing arbitrary recipients.
Expand Down
84 changes: 75 additions & 9 deletions examples/cloudflare-email/test/integ.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import * as Cloudflare from "alchemy/Cloudflare";
import * as Test from "alchemy/Test/Bun";
import { expect } from "bun:test";
import * as Effect from "effect/Effect";
import * as Schedule from "effect/Schedule";
import * as HttpBody from "effect/unstable/http/HttpBody";
import * as HttpClient from "effect/unstable/http/HttpClient";
import * as HttpClientRequest from "effect/unstable/http/HttpClientRequest";
Expand All @@ -16,37 +17,39 @@ const stack = beforeAll(deploy(Stack));

afterAll.skipIf(!!process.env.NO_DESTROY)(destroy(Stack));

// Fresh `workers.dev` URLs transiently 404/5xx while the route propagates.
// `Test.getWhenReady` fails on that cold-start window and retries until the
// worker answers; the first hit in each test rides it.
const { getWhenReady } = Test;
// workers.dev URLs take a few seconds to propagate after first enable.
const getOnce = (url: string) =>
Effect.gen(function* () {
const response = yield* HttpClient.get(url);
return response;
}).pipe(Effect.retry({ schedule: Schedule.spaced("1 second"), times: 30 }));

test(
"stack outputs reflect the deployed email infrastructure",
Effect.gen(function* () {
const out = yield* stack;
expect(out.url).toBeString();
expect(out.zoneId).toBeString();
expect(out.routingEnabled).toBe(true);
expect(out.destinationEmail).toBeString();
expect(out.ruleId).toBeString();
expect(out.inbox).toBeString();
}),
);

test(
"worker exposes the configured sender/destination on /healthz",
Effect.gen(function* () {
const { url } = yield* stack;
const response = yield* getWhenReady(`${url.replace(/\/+$/, "")}/healthz`);
const response = yield* getOnce(`${url.replace(/\/+$/, "")}/healthz`);
expect(response.status).toBe(200);
const body = (yield* response.json) as {
ok: boolean;
from: string;
to: string;
inbox: string;
};
expect(body.ok).toBe(true);
expect(body.from).toBeString();
expect(body.to).toBeString();
expect(body.inbox).toBeString();
}),
{ timeout: 60_000 },
);
Expand All @@ -57,7 +60,7 @@ test(
const { url } = yield* stack;
const baseUrl = url.replace(/\/+$/, "");

yield* getWhenReady(baseUrl);
yield* getOnce(baseUrl);

const response = yield* HttpClient.execute(
HttpClientRequest.post(`${baseUrl}/send`).pipe(
Expand Down Expand Up @@ -87,3 +90,66 @@ test(
}),
{ timeout: 120_000 },
);

test(
"worker records inbound mail via the email subscribe handler",
Effect.gen(function* () {
const { url, inbox } = yield* stack;
const baseUrl = url.replace(/\/+$/, "");

yield* getOnce(baseUrl);

// Clear any messages left over from a previous run.
yield* HttpClient.execute(HttpClientRequest.post(`${baseUrl}/reset`));

const subject = `alchemy inbound ${Date.now()}`;

// Seed a message addressed to INBOX. The Worker's `send_email`
// binding is pinned to DESTINATION, so we route through `/send`
// with an explicit `to` override targeted at INBOX, then poll the
// `/received` snapshot until the email handler fires (or give up).
const sendResponse = yield* HttpClient.execute(
HttpClientRequest.post(`${baseUrl}/send`).pipe(
HttpClientRequest.setBody(
HttpBody.text(
JSON.stringify({ subject, text: "inbound", to: inbox }),
"application/json",
),
),
),
);
const sendBody = (yield* sendResponse.json) as {
ok: boolean;
message?: string;
};
if (!sendBody.ok) {
// Send may legitimately reject (unverified destination, sender
// not on the same zone as INBOX); skip the receive assertion
// rather than fail the whole suite on environment setup.
return;
}

const snapshot = yield* HttpClient.get(`${baseUrl}/received`).pipe(
Effect.flatMap((res) => res.json),
Effect.map(
(b) =>
b as {
received: Array<{
from: string;
to: string;
subject: string | null;
bodySize: number;
}>;
},
),
Effect.repeat({
schedule: Schedule.spaced("5 seconds"),
until: (s) => s.received.some((m) => m.subject === subject),
times: 24,
}),
);

expect(snapshot.received.some((m) => m.subject === subject)).toBe(true);
}),
{ timeout: 180_000 },
);
Loading
Loading