1import * as emailRouting from "@distilled.cloud/cloudflare/email-routing";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
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";
12import type { Action } from "./Rule.ts";
13
14const CatchAllTypeId = "Cloudflare.Email.CatchAll" as const;
15type CatchAllTypeId = typeof CatchAllTypeId;
16
17export type CatchAllProps = {
18 /**
19 * Zone whose catch-all rule to manage. Accepts a zone id, a zone name
20 * (`example.com`), or a `{ zoneId, name? }` object. Stable — the
21 * catch-all rule is a per-zone singleton, so changing the zone triggers
22 * a replacement (the old zone's catch-all is restored to the state it
23 * had before Alchemy managed it).
24 */
25 zone: Reference;
26 /**
27 * Display name for the catch-all rule. When omitted, the current name
28 * on the rule is left untouched.
29 */
30 name?: string;
31 /**
32 * Whether the catch-all rule is active.
33 *
34 * @default true
35 */
36 enabled?: boolean;
37 /**
38 * Actions to take for emails that match no other routing rule
39 * (`drop`, `forward` to verified destination addresses, or `worker`).
40 * Matchers are fixed to `[{ type: "all" }]` by the API.
41 */
42 actions: Action[];
43};
44
45export type CatchAllAttributes = {
46 /** Routing rule identifier of the zone's catch-all rule. */
47 ruleId: string;
48 /** Zone the catch-all rule belongs to. */
49 zoneId: string;
50 /** Display name of the catch-all rule. */
51 name: string;
52 /** Whether the catch-all rule is active. */
53 enabled: boolean;
54 /** Actions taken for emails that match no other routing rule. */
55 actions: Action[];
56 /**
57 * The name the catch-all rule had before Alchemy first managed it.
58 * Restored on destroy.
59 */
60 initialName: string;
61 /**
62 * Whether the catch-all rule was enabled before Alchemy first managed
63 * it. Restored on destroy.
64 */
65 initialEnabled: boolean;
66 /**
67 * The actions the catch-all rule had before Alchemy first managed it.
68 * Restored on destroy.
69 */
70 initialActions: Action[];
71};
72
73export type CatchAll = Resource<
74 CatchAllTypeId,
75 CatchAllProps,
76 CatchAllAttributes,
77 never,
78 Providers
79>;
80
81/**
82 * The Cloudflare Email Routing catch-all rule for a zone.
83 *
84 * The catch-all rule handles every inbound email that no other routing
85 * rule matched. It is a per-zone singleton — once Email Routing is enabled
86 * the rule always exists (disabled, dropping mail, by default), so this
87 * resource never creates or deletes anything physical. Reconcile `PUT`s the
88 * desired configuration; destroy restores the configuration the rule had
89 * before Alchemy first managed it.
90 *
91 * Email Routing must be enabled on the zone first (see
92 * `Cloudflare.Email.Routing`), and `forward` actions require the destination
93 * address to be verified (see `Cloudflare.Email.Address`).
94 * ### Catching unmatched mail
95 * **Example:** Forward everything else to a verified destination
96 * ```typescript
97 * const routing = yield* Cloudflare.Email.Routing("Routing", {
98 * zone: "example.com",
99 * });
100 *
101 * yield* Cloudflare.Email.CatchAll("CatchAll", {
102 * zone: routing.zoneId,
103 * actions: [{ type: "forward", value: ["ops@example.com"] }],
104 * });
105 * ```
106 *
107 * **Example:** Silently drop unmatched mail
108 * ```typescript
109 * yield* Cloudflare.Email.CatchAll("DropTheRest", {
110 * zone: routing.zoneId,
111 * name: "drop unmatched",
112 * actions: [{ type: "drop" }],
113 * });
114 * ```
115 *
116 * ### Workers
117 * **Example:** Hand unmatched mail to an email Worker
118 * ```typescript
119 * yield* Cloudflare.Email.CatchAll("CatchAllWorker", {
120 * zone: routing.zoneId,
121 * actions: [{ type: "worker", value: ["my-email-worker"] }],
122 * });
123 * ```
124 *
125 * @resource
126 * @product Email
127 * @category Email
128 */
129export const CatchAll = Resource<CatchAll>(CatchAllTypeId, {
130 aliases: ["Cloudflare.EmailCatchAll"],
131});
132
133/**
134 * Returns true if the given value is an CatchAll resource.
135 */
136export const isCatchAll = (value: unknown): value is CatchAll =>
137 Predicate.hasProperty(value, "Type") && value.Type === CatchAllTypeId;
138
139export const CatchAllProvider = () =>
140 Provider.succeed(CatchAll, {
141 nuke: { singleton: true },
142 stables: [
143 "ruleId",
144 "zoneId",
145 "initialName",
146 "initialEnabled",
147 "initialActions",
148 ],
149
150 list: Effect.fn(function* () {
151 const { accountId } = yield* yield* CloudflareEnvironment;
152 // The catch-all rule is a per-zone singleton with no account-wide
153 // enumeration API — enumerate every zone and read its rule. The
154 // observed state at read time is the initial* baseline.
155 const allZones = yield* listAllZones(accountId);
156 const rows = yield* Effect.forEach(
157 allZones.map((zone) => zone.id),
158 (zoneId) =>
159 emailRouting.getRuleCatchAll({ zoneId }).pipe(
160 Effect.map((observed) =>
161 toAttributes(zoneId, observed, observedInitial(observed)),
162 ),
163 // Zones without Email Routing enabled (or that the token can no
164 // longer see) reject the route; skip them.
165 Effect.catchTag("Forbidden", () => Effect.succeed(undefined)),
166 ),
167 { concurrency: 10 },
168 );
169 return rows.filter((row): row is CatchAllAttributes => row !== undefined);
170 }),
171
172 diff: Effect.fn(function* ({ news, output }) {
173 if (!output) return undefined;
174 if (!isResolved(news)) return undefined;
175 const zoneId = yield* resolve(news.zone);
176 if (zoneId !== output.zoneId) {
177 // Different zone = different singleton.
178 return { action: "replace" } as const;
179 }
180 return undefined;
181 }),
182
183 read: Effect.fn(function* ({ output, olds }) {
184 const zoneId =
185 // `olds.zone` may be `undefined` when a `creating` row was persisted
186 // before upstream Outputs resolved — report "not found" then.
187 output?.zoneId ??
188 (olds?.zone !== undefined ? yield* resolve(olds.zone) : undefined);
189 if (!zoneId) return undefined;
190 const observed = yield* emailRouting.getRuleCatchAll({ zoneId }).pipe(
191 // Zone deleted out-of-band (or the token can no longer see it) —
192 // the catch-all rule is gone with it.
193 Effect.catchTag("Forbidden", () => Effect.succeed(undefined)),
194 );
195 if (observed === undefined) return undefined;
196 // The catch-all rule is a singleton that always exists once Email
197 // Routing is enabled — there is nothing to "own", so a cold read
198 // adopts freely (never `Unowned`). The observed state at adoption
199 // time becomes the initial* baseline restored on destroy.
200 const initial = output ?? observedInitial(observed);
201 return toAttributes(zoneId, observed, initial);
202 }),
203
204 reconcile: Effect.fn(function* ({ news, output }) {
205 // 1. Observe — the catch-all rule always exists once Email Routing
206 // is enabled on the zone; read its live state.
207 const zoneId = output?.zoneId ?? (yield* resolve(news.zone));
208 const observed = yield* emailRouting.getRuleCatchAll({ zoneId });
209
210 // 2. Capture — the pre-management state, restored on destroy.
211 // `output` (including an adoption read) already carries it;
212 // otherwise this is our first touch and the observed state is
213 // the zone's original.
214 const initial = output ?? observedInitial(observed);
215
216 // 3. Sync — PUT only when the observed state differs from desired.
217 const desiredEnabled = news.enabled ?? true;
218 const desiredName = news.name ?? observed.name ?? "";
219 if (
220 (observed.enabled ?? false) === desiredEnabled &&
221 (observed.name ?? "") === desiredName &&
222 actionsEqual(normalizeActions(observed.actions), news.actions)
223 ) {
224 return toAttributes(zoneId, observed, initial);
225 }
226 const result = yield* emailRouting
227 .putRuleCatchAll({
228 zoneId,
229 matchers: [{ type: "all" }],
230 actions: news.actions.map((a) =>
231 a.type === "drop"
232 ? { type: a.type }
233 : { type: a.type, value: a.value },
234 ),
235 enabled: desiredEnabled,
236 name: desiredName,
237 })
238 .pipe(retryWorkerScriptNotFound);
239 return toAttributes(zoneId, result, initial);
240 }),
241
242 delete: Effect.fn(function* ({ output }) {
243 const { zoneId, initialName, initialEnabled, initialActions } = output;
244 // Observe — if the zone itself is gone, so is the catch-all rule.
245 const observed = yield* emailRouting
246 .getRuleCatchAll({ zoneId })
247 .pipe(Effect.catchTag("Forbidden", () => Effect.succeed(undefined)));
248 if (observed === undefined) return;
249 // Restore the pre-management state; skip the call when it already
250 // matches (idempotent re-delete after a crashed run).
251 if (
252 (observed.enabled ?? false) === initialEnabled &&
253 (observed.name ?? "") === initialName &&
254 actionsEqual(normalizeActions(observed.actions), initialActions)
255 ) {
256 return;
257 }
258 yield* emailRouting
259 .putRuleCatchAll({
260 zoneId,
261 matchers: [{ type: "all" }],
262 actions: initialActions.map((a) =>
263 a.type === "drop"
264 ? { type: a.type }
265 : { type: a.type, value: a.value },
266 ),
267 enabled: initialEnabled,
268 name: initialName,
269 })
270 .pipe(
271 Effect.catchTag("Forbidden", () => Effect.void),
272 // The captured action may name something that no longer exists:
273 // an unverified/removed destination address, or — after the
274 // Worker it pointed at was replaced or deleted — a missing
275 // script. Neither is retryable and neither should strand the
276 // destroy, so fall back to the Cloudflare default (disabled,
277 // drop) instead of failing.
278 Effect.catchTag(
279 ["DestinationNotVerified", "WorkerScriptNotFound"],
280 () =>
281 emailRouting
282 .putRuleCatchAll({
283 zoneId,
284 matchers: [{ type: "all" }],
285 actions: [{ type: "drop" }],
286 enabled: false,
287 name: initialName,
288 })
289 .pipe(Effect.catchTag("Forbidden", () => Effect.void)),
290 ),
291 );
292 }),
293 });
294
295type ObservedCatchAll =
296 | emailRouting.GetRuleCatchAllResponse
297 | emailRouting.PutRuleCatchAllResponse;
298
299const normalizeActions = (actions: ObservedCatchAll["actions"]): Action[] =>
300 (actions ?? []).map((a): Action =>
301 a.type === "drop"
302 ? { type: "drop" }
303 : a.type === "forward"
304 ? { type: "forward", value: [...(a.value ?? [])] }
305 : { type: "worker", value: [...(a.value ?? [])] },
306 );
307
308const actionsEqual = (a: Action[], b: Action[]): boolean =>
309 a.length === b.length &&
310 a.every((x, i) => {
311 const y = b[i]!;
312 if (x.type !== y.type) return false;
313 const xv = x.type === "drop" ? [] : x.value;
314 const yv = y.type === "drop" ? [] : y.value;
315 return xv.length === yv.length && xv.every((v, j) => v === yv[j]);
316 });
317
318const observedInitial = (observed: ObservedCatchAll) => ({
319 initialName: observed.name ?? "",
320 initialEnabled: observed.enabled ?? false,
321 initialActions: normalizeActions(observed.actions),
322});
323
324const toAttributes = (
325 zoneId: string,
326 observed: ObservedCatchAll,
327 initial: {
328 initialName: string;
329 initialEnabled: boolean;
330 initialActions: Action[];
331 },
332): CatchAllAttributes => ({
333 ruleId: observed.id ?? "",
334 zoneId,
335 name: observed.name ?? "",
336 enabled: observed.enabled ?? false,
337 actions: normalizeActions(observed.actions),
338 initialName: initial.initialName,
339 initialEnabled: initial.initialEnabled,
340 initialActions: initial.initialActions,
341});
342
343const resolve = Effect.fn(function* (zone: Reference) {
344 const { accountId } = yield* yield* CloudflareEnvironment;
345 return yield* resolveZoneId({
346 accountId,
347 zone,
348 hostname: typeof zone === "string" ? zone : (zone.name ?? ""),
349 });
350});