| 1 | import * as emailRouting from "@distilled.cloud/cloudflare/email-routing"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Predicate from "effect/Predicate"; |
| 4 | import { isResolved } from "../../Diff.ts"; |
| 5 | import * as Provider from "../../Provider.ts"; |
| 6 | import { Resource } from "../../Resource.ts"; |
| 7 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 8 | import type { Providers } from "../Providers.ts"; |
| 9 | import { resolveZoneId, type Reference } from "../Zone/index.ts"; |
| 10 | import { listAllZones } from "../Zone/lookup.ts"; |
| 11 | import { retryWorkerScriptNotFound } from "./retry.ts"; |
| 12 | import type { Action } from "./Rule.ts"; |
| 13 | |
| 14 | const CatchAllTypeId = "Cloudflare.Email.CatchAll" as const; |
| 15 | type CatchAllTypeId = typeof CatchAllTypeId; |
| 16 | |
| 17 | export 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 | |
| 45 | export 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 | |
| 73 | export 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 | */ |
| 129 | export const CatchAll = Resource<CatchAll>(CatchAllTypeId, { |
| 130 | aliases: ["Cloudflare.EmailCatchAll"], |
| 131 | }); |
| 132 | |
| 133 | /** |
| 134 | * Returns true if the given value is an CatchAll resource. |
| 135 | */ |
| 136 | export const isCatchAll = (value: unknown): value is CatchAll => |
| 137 | Predicate.hasProperty(value, "Type") && value.Type === CatchAllTypeId; |
| 138 | |
| 139 | export 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 | |
| 295 | type ObservedCatchAll = |
| 296 | | emailRouting.GetRuleCatchAllResponse |
| 297 | | emailRouting.PutRuleCatchAllResponse; |
| 298 | |
| 299 | const 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 | |
| 308 | const 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 | |
| 318 | const observedInitial = (observed: ObservedCatchAll) => ({ |
| 319 | initialName: observed.name ?? "", |
| 320 | initialEnabled: observed.enabled ?? false, |
| 321 | initialActions: normalizeActions(observed.actions), |
| 322 | }); |
| 323 | |
| 324 | const 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 | |
| 343 | const 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 | }); |