1import * as emailRouting from "@distilled.cloud/cloudflare/email-routing";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
8import type { Providers } from "../Providers.ts";
9import { resolveZoneId, type Reference } from "../Zone/index.ts";
10import { listAllZones } from "../Zone/lookup.ts";
11import { retryWorkerScriptNotFound } from "./retry.ts";
12
13export type Matcher =
14 | { type: "all" }
15 | { type: "literal"; field: "to"; value: string };
16
17export type Action =
18 | { type: "drop" }
19 | { type: "forward"; value: string[] }
20 | { type: "worker"; value: string[] };
21
22export type RuleProps = {
23 /**
24 * Zone the rule lives on.
25 */
26 zone: Reference;
27 /**
28 * Display name for the rule.
29 */
30 name?: string;
31 /**
32 * Whether the rule is active. Disabled rules are evaluated last and
33 * effectively skipped.
34 *
35 * @default true
36 */
37 enabled?: boolean;
38 /**
39 * Lower priority numbers run first.
40 *
41 * @default 0
42 */
43 priority?: number;
44 /**
45 * Matchers that define which inbound emails trigger this rule.
46 */
47 matchers: Matcher[];
48 /**
49 * Actions to take for matched emails.
50 */
51 actions: Action[];
52};
53
54export type Rule = Resource<
55 "Cloudflare.Email.Rule",
56 RuleProps,
57 {
58 ruleId: string;
59 zoneId: string;
60 name: string;
61 enabled: boolean;
62 priority: number;
63 matchers: Matcher[];
64 actions: Action[];
65 },
66 never,
67 Providers
68>;
69
70/**
71 * A Cloudflare Email Routing rule.
72 *
73 * Rules forward inbound mail matching `matchers` to the listed actions
74 * (forward to a verified destination, drop, or hand off to a Worker).
75 * ### Forwarding Mail
76 * **Example:** Forward `info@` to a verified destination
77 * ```typescript
78 * const rule = yield* Cloudflare.Email.Rule("InfoForward", {
79 * zone: "example.com",
80 * matchers: [{ type: "literal", field: "to", value: "info@example.com" }],
81 * actions: [{ type: "forward", value: ["ops@example.com"] }],
82 * });
83 * ```
84 *
85 * @resource
86 * @product Email
87 * @category Email
88 */
89export const Rule = Resource<Rule>("Cloudflare.Email.Rule", {
90 aliases: ["Cloudflare.EmailRule"],
91});
92
93export const RuleProvider = () =>
94 Provider.succeed(Rule, {
95 stables: ["ruleId", "zoneId"],
96 list: Effect.fn(function* () {
97 const { accountId } = yield* yield* CloudflareEnvironment;
98 // Email routing rules are zone-scoped (`/zones/{id}/email/routing/rules`)
99 // with no account-wide enumeration API — fan out over every zone and
100 // exhaustively paginate each zone's rules.
101 const zones = yield* listAllZones(accountId);
102 const rows = yield* Effect.forEach(
103 zones,
104 (zone) =>
105 emailRouting.listRules.pages({ zoneId: zone.id }).pipe(
106 Stream.runCollect,
107 Effect.map((chunk) =>
108 Array.from(chunk).flatMap((page) =>
109 (page.result ?? [])
110 // Cloudflare returns the zone's catch-all rule in this list,
111 // but it's a managed singleton (owned by `CatchAll`, via
112 // `/rules/catch_all`) and rejects deletion through the regular
113 // rule endpoint ("Invalid rule operation"). Identify it by its
114 // sole `{ type: "all" }` matcher and exclude it.
115 .filter((rule) => !isCatchAllRule(rule))
116 .map((rule) => normalize(rule, zone.id)),
117 ),
118 ),
119 // Zones without email routing (or otherwise non-routable) reject
120 // the route; skip them rather than failing the whole listing.
121 Effect.catchTag("InvalidRoute", () => Effect.succeed([])),
122 ),
123 { concurrency: 10 },
124 );
125 return rows.flat();
126 }),
127 diff: Effect.fn(function* ({ news, output }) {
128 if (!output) return undefined;
129 if (!isResolved(news)) return undefined;
130 const zoneId = yield* resolve(news.zone);
131 if (zoneId !== output.zoneId) {
132 return { action: "replace" } as const;
133 }
134 return undefined;
135 }),
136 read: Effect.fn(function* ({ output }) {
137 if (!output?.ruleId || !output?.zoneId) return undefined;
138 return yield* emailRouting
139 .getRule({
140 zoneId: output.zoneId,
141 ruleIdentifier: output.ruleId,
142 })
143 .pipe(
144 Effect.map((r) => normalize(r, output.zoneId)),
145 Effect.catch(() => Effect.succeed(undefined)),
146 );
147 }),
148 reconcile: Effect.fn(function* ({ news, output }) {
149 const zoneId = output?.zoneId ?? (yield* resolve(news.zone));
150 const body = {
151 actions: news.actions.map((a) =>
152 a.type === "drop"
153 ? { type: a.type }
154 : { type: a.type, value: a.value },
155 ),
156 matchers: news.matchers.map((m) =>
157 m.type === "all"
158 ? { type: "all" as const }
159 : {
160 type: "literal" as const,
161 field: "to" as const,
162 value: m.value,
163 },
164 ),
165 enabled: news.enabled ?? true,
166 name: news.name ?? "",
167 priority: news.priority ?? 0,
168 };
169
170 if (output?.ruleId) {
171 const result = yield* emailRouting
172 .updateRule({
173 zoneId,
174 ruleIdentifier: output.ruleId,
175 ...body,
176 })
177 .pipe(
178 retryWorkerScriptNotFound,
179 Effect.catch(() =>
180 emailRouting
181 .createRule({ zoneId, ...body })
182 .pipe(retryWorkerScriptNotFound),
183 ),
184 );
185 return normalize(result, zoneId);
186 }
187
188 const result = yield* emailRouting
189 .createRule({ zoneId, ...body })
190 .pipe(retryWorkerScriptNotFound);
191 return normalize(result, zoneId);
192 }),
193 delete: Effect.fn(function* ({ output }) {
194 if (!output?.ruleId) return;
195 // Idempotent: a rule that's already gone is success. Any other error
196 // (e.g. a 409 because email routing is disabled) must surface so the
197 // engine reports the failure instead of falsely claiming deletion.
198 yield* emailRouting
199 .deleteRule({
200 zoneId: output.zoneId,
201 ruleIdentifier: output.ruleId,
202 })
203 .pipe(Effect.catchTag("EmailRoutingRuleNotFound", () => Effect.void));
204 }),
205 });
206
207/**
208 * The zone catch-all rule is surfaced by `listRules` but is a managed
209 * singleton — its sole matcher is `{ type: "all" }`. It can only be mutated
210 * via `/rules/catch_all` (the `CatchAll` resource), so it must be
211 * excluded from the deletable `Rule` enumeration.
212 */
213const isCatchAllRule = (rule: {
214 matchers?: { type: string }[] | null;
215}): boolean =>
216 (rule.matchers ?? []).length === 1 && rule.matchers?.[0]?.type === "all";
217
218const normalize = (
219 rule: {
220 id?: string | null;
221 name?: string | null;
222 enabled?: boolean | null;
223 priority?: number | null;
224 // Distilled widened generated string enums to open unions (`string & {}`);
225 // the runtime values are still the known variants, narrowed below.
226 matchers?:
227 | {
228 type: string;
229 field?: string | null;
230 value?: string | null;
231 }[]
232 | null;
233 actions?: { type: string; value?: string[] | null }[] | null;
234 },
235 zoneId: string,
236) => ({
237 ruleId: rule.id ?? "",
238 zoneId,
239 name: rule.name ?? "",
240 enabled: rule.enabled ?? true,
241 priority: rule.priority ?? 0,
242 matchers: (rule.matchers ?? []).map((m): Matcher =>
243 m.type === "all"
244 ? { type: "all" }
245 : { type: "literal", field: "to", value: m.value ?? "" },
246 ),
247 actions: (rule.actions ?? []).map((a): Action =>
248 a.type === "drop"
249 ? { type: "drop" }
250 : a.type === "forward"
251 ? { type: "forward", value: a.value ?? [] }
252 : { type: "worker", value: a.value ?? [] },
253 ),
254});
255
256const resolve = Effect.fn(function* (zone: Reference) {
257 const { accountId } = yield* yield* CloudflareEnvironment;
258 return yield* resolveZoneId({
259 accountId,
260 zone,
261 hostname: typeof zone === "string" ? zone : (zone.name ?? ""),
262 });
263});