| 1 | import * as emailRouting from "@distilled.cloud/cloudflare/email-routing"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Schedule from "effect/Schedule"; |
| 4 | import * as Stream from "effect/Stream"; |
| 5 | import { isResolved } from "../../Diff.ts"; |
| 6 | import * as Provider from "../../Provider.ts"; |
| 7 | import { Resource } from "../../Resource.ts"; |
| 8 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 9 | import type { Providers } from "../Providers.ts"; |
| 10 | |
| 11 | export type AddressProps = { |
| 12 | /** |
| 13 | * The email address to register as a verified destination on the |
| 14 | * account. Cloudflare sends a verification email to this address; the |
| 15 | * recipient must click the link before the address can receive routed |
| 16 | * mail or be used as a verified sender. |
| 17 | * |
| 18 | * Changing this property triggers a replacement. |
| 19 | */ |
| 20 | email: string; |
| 21 | }; |
| 22 | |
| 23 | export type Address = Resource< |
| 24 | "Cloudflare.Email.Address", |
| 25 | AddressProps, |
| 26 | { |
| 27 | addressId: string; |
| 28 | email: string; |
| 29 | accountId: string; |
| 30 | verified: boolean; |
| 31 | verifiedAt: string | undefined; |
| 32 | created: string | undefined; |
| 33 | modified: string | undefined; |
| 34 | }, |
| 35 | never, |
| 36 | Providers |
| 37 | >; |
| 38 | |
| 39 | /** |
| 40 | * A verified destination email address on the account. |
| 41 | * |
| 42 | * Destination addresses are account-scoped (not zone-scoped). They are used |
| 43 | * as forwarding targets in `Rule` actions and can also serve as the |
| 44 | * `destinationAddress` on a `send_email` Worker binding. |
| 45 | * ### Registering an Address |
| 46 | * **Example:** Register a destination address |
| 47 | * ```typescript |
| 48 | * const ops = yield* Cloudflare.Email.Address("Ops", { |
| 49 | * email: "ops@example.com", |
| 50 | * }); |
| 51 | * ``` |
| 52 | * |
| 53 | * Cloudflare sends a verification email when the address is first created. |
| 54 | * The address must be verified before it can receive routed mail. |
| 55 | * |
| 56 | * @resource |
| 57 | * @product Email |
| 58 | * @category Email |
| 59 | */ |
| 60 | export const Address = Resource<Address>("Cloudflare.Email.Address", { |
| 61 | aliases: ["Cloudflare.EmailAddress"], |
| 62 | }); |
| 63 | |
| 64 | const toAttrs = ( |
| 65 | accountId: string, |
| 66 | result: { |
| 67 | id?: string | null; |
| 68 | email?: string | null; |
| 69 | verified?: string | null; |
| 70 | created?: string | null; |
| 71 | modified?: string | null; |
| 72 | }, |
| 73 | ) => ({ |
| 74 | addressId: result.id ?? "", |
| 75 | email: result.email ?? "", |
| 76 | accountId, |
| 77 | verified: Boolean(result.verified), |
| 78 | verifiedAt: result.verified ?? undefined, |
| 79 | created: result.created ?? undefined, |
| 80 | modified: result.modified ?? undefined, |
| 81 | }); |
| 82 | |
| 83 | // Authoritative account-wide lookup of a destination address by email. The |
| 84 | // per-address `getAddress` identifier is the opaque address id (not the email), |
| 85 | // so the only reliable way to find an address by its email is to enumerate the |
| 86 | // account collection (the same call `list()` exhausts). |
| 87 | const findByEmail = (accountId: string, email: string) => |
| 88 | emailRouting.listAddresses.pages({ accountId }).pipe( |
| 89 | Stream.runCollect, |
| 90 | Effect.map((chunk) => |
| 91 | Array.from(chunk) |
| 92 | .flatMap((page) => page.result ?? []) |
| 93 | .map((addr) => toAttrs(accountId, addr)) |
| 94 | .find((a) => a.email === email), |
| 95 | ), |
| 96 | ); |
| 97 | |
| 98 | export const AddressProvider = () => |
| 99 | Provider.succeed(Address, { |
| 100 | stables: ["addressId", "accountId", "email"], |
| 101 | // Account collection: destination addresses are enumerable account-wide. |
| 102 | list: Effect.fn(function* () { |
| 103 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 104 | return yield* emailRouting.listAddresses.pages({ accountId }).pipe( |
| 105 | Stream.runCollect, |
| 106 | Effect.map((chunk) => |
| 107 | Array.from(chunk).flatMap((page) => |
| 108 | (page.result ?? []).map((addr) => toAttrs(accountId, addr)), |
| 109 | ), |
| 110 | ), |
| 111 | ); |
| 112 | }), |
| 113 | diff: Effect.fn(function* ({ news, output }) { |
| 114 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 115 | if (!output) return undefined; |
| 116 | if (output.accountId !== accountId) { |
| 117 | return { action: "replace" } as const; |
| 118 | } |
| 119 | if (!isResolved(news)) return undefined; |
| 120 | if (news.email !== output.email) { |
| 121 | return { action: "replace" } as const; |
| 122 | } |
| 123 | return undefined; |
| 124 | }), |
| 125 | read: Effect.fn(function* ({ output, olds }) { |
| 126 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 127 | const identifier = |
| 128 | output?.addressId ?? |
| 129 | (olds?.email ? encodeURIComponent(olds.email) : undefined); |
| 130 | if (!identifier) return undefined; |
| 131 | const acct = output?.accountId ?? accountId; |
| 132 | return yield* emailRouting |
| 133 | .getAddress({ |
| 134 | accountId: acct, |
| 135 | destinationAddressIdentifier: identifier, |
| 136 | }) |
| 137 | .pipe( |
| 138 | Effect.map((r) => toAttrs(acct, r)), |
| 139 | Effect.catch(() => Effect.succeed(undefined)), |
| 140 | ); |
| 141 | }), |
| 142 | reconcile: Effect.fn(function* ({ news, output }) { |
| 143 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 144 | const acct = output?.accountId ?? accountId; |
| 145 | const email = news.email; |
| 146 | |
| 147 | // Observe — by addressId if known, else by email lookup. |
| 148 | let observed: ReturnType<typeof toAttrs> | undefined = output?.addressId |
| 149 | ? yield* emailRouting |
| 150 | .getAddress({ |
| 151 | accountId: acct, |
| 152 | destinationAddressIdentifier: output.addressId, |
| 153 | }) |
| 154 | .pipe( |
| 155 | Effect.map((r) => toAttrs(acct, r)), |
| 156 | Effect.catch(() => Effect.succeed(undefined)), |
| 157 | ) |
| 158 | : undefined; |
| 159 | |
| 160 | if (!observed) { |
| 161 | observed = yield* emailRouting |
| 162 | .getAddress({ |
| 163 | accountId: acct, |
| 164 | destinationAddressIdentifier: encodeURIComponent(email), |
| 165 | }) |
| 166 | .pipe( |
| 167 | Effect.map((r) => toAttrs(acct, r)), |
| 168 | Effect.catch(() => Effect.succeed(undefined)), |
| 169 | ); |
| 170 | } |
| 171 | |
| 172 | // Ensure — register the address if it doesn't already exist. |
| 173 | if (!observed) { |
| 174 | observed = yield* emailRouting |
| 175 | .createAddress({ accountId: acct, email }) |
| 176 | .pipe( |
| 177 | Effect.map((created) => toAttrs(acct, created)), |
| 178 | // Cloudflare rate-limits verification emails per destination |
| 179 | // address ("Verification email has been sent too recently"). When |
| 180 | // the same address was (re)created recently the address record |
| 181 | // already exists account-wide, so adopt it instead of failing. |
| 182 | // Re-raise if the address genuinely isn't present. |
| 183 | Effect.catchTag("TooManyRequests", (error) => |
| 184 | findByEmail(acct, email).pipe( |
| 185 | Effect.flatMap((found) => |
| 186 | found ? Effect.succeed(found) : Effect.fail(error), |
| 187 | ), |
| 188 | ), |
| 189 | ), |
| 190 | ); |
| 191 | } |
| 192 | |
| 193 | return observed; |
| 194 | }), |
| 195 | delete: Effect.fn(function* ({ output }) { |
| 196 | if (!output?.addressId) return; |
| 197 | // Idempotent on the typed not-found; transient failures are retried |
| 198 | // bounded and a persistent failure surfaces — a swallowed failure |
| 199 | // silently leaks the destination address. Cloudflare refuses to |
| 200 | // delete an address for ~15 minutes after creation |
| 201 | // (`EmailAddressCreatedTooRecently`, code 2032) — retrying is |
| 202 | // pointless within a deploy's lifetime, so that error fails fast: |
| 203 | // either destroy later or retain the address (`RemovalPolicy.retain`). |
| 204 | yield* emailRouting |
| 205 | .deleteAddress({ |
| 206 | accountId: output.accountId, |
| 207 | destinationAddressIdentifier: output.addressId, |
| 208 | }) |
| 209 | .pipe( |
| 210 | Effect.catchTag("EmailAddressNotFound", () => Effect.void), |
| 211 | Effect.retry({ |
| 212 | while: (e) => e._tag !== "EmailAddressCreatedTooRecently", |
| 213 | schedule: Schedule.max([ |
| 214 | Schedule.spaced("3 seconds"), |
| 215 | Schedule.recurs(8), |
| 216 | ]), |
| 217 | }), |
| 218 | ); |
| 219 | }), |
| 220 | }); |