| 1 | import * as emailSecurity from "@distilled.cloud/cloudflare/email-security"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Predicate from "effect/Predicate"; |
| 4 | import * as Stream from "effect/Stream"; |
| 5 | |
| 6 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 7 | import * as Provider from "../../Provider.ts"; |
| 8 | import { Resource } from "../../Resource.ts"; |
| 9 | import { CloudflareEnvironment } from "../CloudflareEnvironment.ts"; |
| 10 | import type { Providers } from "../Providers.ts"; |
| 11 | |
| 12 | const EmailSecurityTrustedDomainTypeId = |
| 13 | "Cloudflare.Email.TrustedDomain" as const; |
| 14 | type EmailSecurityTrustedDomainTypeId = typeof EmailSecurityTrustedDomainTypeId; |
| 15 | |
| 16 | export interface TrustedDomainProps { |
| 17 | /** |
| 18 | * The domain (or regular expression) to trust. The pattern is the |
| 19 | * entry's identity for cold-state recovery — a pre-existing entry with |
| 20 | * the same pattern is reported as unowned and only taken over when |
| 21 | * adoption is enabled. |
| 22 | */ |
| 23 | pattern: string; |
| 24 | /** |
| 25 | * Prevents recently registered domains from triggering a Suspicious or |
| 26 | * Malicious disposition. |
| 27 | * @default false |
| 28 | */ |
| 29 | isRecent?: boolean; |
| 30 | /** |
| 31 | * For partner or other approved domains with similar spelling to your |
| 32 | * connected domains — prevents the listed domains from triggering a |
| 33 | * Spoof disposition. |
| 34 | * @default false |
| 35 | */ |
| 36 | isSimilarity?: boolean; |
| 37 | /** |
| 38 | * Whether `pattern` is a regular expression. |
| 39 | * @default false |
| 40 | */ |
| 41 | isRegex?: boolean; |
| 42 | /** |
| 43 | * Free-form notes about the trusted domain. |
| 44 | */ |
| 45 | comments?: string; |
| 46 | } |
| 47 | |
| 48 | export interface TrustedDomainAttributes { |
| 49 | /** Cloudflare-assigned trusted domain identifier. */ |
| 50 | trustedDomainId: string; |
| 51 | /** The account the entry belongs to. */ |
| 52 | accountId: string; |
| 53 | /** The trusted domain pattern. */ |
| 54 | pattern: string; |
| 55 | /** Whether recently registered domain protection is disabled. */ |
| 56 | isRecent: boolean; |
| 57 | /** Whether lookalike/partner domain Spoof protection is disabled. */ |
| 58 | isSimilarity: boolean; |
| 59 | /** Whether the pattern is a regular expression. */ |
| 60 | isRegex: boolean; |
| 61 | /** Free-form notes about the entry, if set. */ |
| 62 | comments: string | undefined; |
| 63 | /** ISO8601 creation timestamp. */ |
| 64 | createdAt: string; |
| 65 | /** ISO8601 last-modified timestamp, if the entry has been modified. */ |
| 66 | modifiedAt: string | undefined; |
| 67 | } |
| 68 | |
| 69 | export type TrustedDomain = Resource< |
| 70 | EmailSecurityTrustedDomainTypeId, |
| 71 | TrustedDomainProps, |
| 72 | TrustedDomainAttributes, |
| 73 | never, |
| 74 | Providers |
| 75 | >; |
| 76 | |
| 77 | /** |
| 78 | * A Cloudflare Email Security (Area 1) trusted domain — exempts a domain |
| 79 | * from recently-registered and lookalike (similarity) detections. |
| 80 | * |
| 81 | * All fields are mutable in place. Requires the Email Security enterprise |
| 82 | * add-on; accounts without the entitlement receive the typed |
| 83 | * `EmailSecurityNotEntitled` error. |
| 84 | * ### Trusting Domains |
| 85 | * **Example:** Trust a partner domain with similar spelling |
| 86 | * ```typescript |
| 87 | * yield* Cloudflare.Email.TrustedDomain("PartnerLookalike", { |
| 88 | * pattern: "examp1e-partner.com", |
| 89 | * isSimilarity: true, |
| 90 | * comments: "legitimate partner domain", |
| 91 | * }); |
| 92 | * ``` |
| 93 | * |
| 94 | * **Example:** Trust a recently registered domain |
| 95 | * ```typescript |
| 96 | * yield* Cloudflare.Email.TrustedDomain("NewSubsidiary", { |
| 97 | * pattern: "brand-new-subsidiary.example", |
| 98 | * isRecent: true, |
| 99 | * }); |
| 100 | * ``` |
| 101 | * |
| 102 | * @see https://developers.cloudflare.com/cloudflare-one/email-security/ |
| 103 | * |
| 104 | * @resource |
| 105 | * @product Email Security |
| 106 | * @category Email |
| 107 | */ |
| 108 | export const TrustedDomain = Resource<TrustedDomain>( |
| 109 | EmailSecurityTrustedDomainTypeId, |
| 110 | { aliases: ["Cloudflare.EmailSecurity.TrustedDomain"] }, |
| 111 | ); |
| 112 | |
| 113 | /** |
| 114 | * Returns true if the given value is an TrustedDomain resource. |
| 115 | */ |
| 116 | export const isTrustedDomain = (value: unknown): value is TrustedDomain => |
| 117 | Predicate.hasProperty(value, "Type") && |
| 118 | value.Type === EmailSecurityTrustedDomainTypeId; |
| 119 | |
| 120 | export const TrustedDomainProvider = () => |
| 121 | Provider.succeed(TrustedDomain, { |
| 122 | stables: ["trustedDomainId", "accountId", "createdAt"], |
| 123 | |
| 124 | // Account-scoped collection. Exhaustively paginate the account's trusted |
| 125 | // domains and hydrate each into the `read` Attributes shape. Accounts |
| 126 | // without the Email Security add-on surface the typed |
| 127 | // `EmailSecurityNotEntitled` error — treat that as an empty enumeration. |
| 128 | list: Effect.fn(function* () { |
| 129 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 130 | return yield* emailSecurity.listSettingTrustedDomains |
| 131 | .pages({ accountId }) |
| 132 | .pipe( |
| 133 | Stream.runCollect, |
| 134 | Effect.map((chunk) => |
| 135 | Array.from(chunk).flatMap((page) => |
| 136 | (page.result ?? []).map((entry) => |
| 137 | toAttributes(entry, accountId), |
| 138 | ), |
| 139 | ), |
| 140 | ), |
| 141 | // Email Security is a paid add-on gated by both account |
| 142 | // entitlement and token scope: an unentitled account answers |
| 143 | // `EmailSecurityNotEntitled`, while a credential lacking the |
| 144 | // Email Security scope (e.g. Cloudflare OAuth) answers a bare |
| 145 | // `Forbidden`. Neither can enumerate, so both mean "none |
| 146 | // visible" — matching `Domain.list()`. Returning `[]` is the |
| 147 | // safe direction for the callers of `list` (orphan detection |
| 148 | // never deletes what it cannot see). |
| 149 | Effect.catchTag(["EmailSecurityNotEntitled", "Forbidden"], () => |
| 150 | Effect.succeed([] as TrustedDomainAttributes[]), |
| 151 | ), |
| 152 | ); |
| 153 | }), |
| 154 | |
| 155 | read: Effect.fn(function* ({ output, olds }) { |
| 156 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 157 | const acct = output?.accountId ?? accountId; |
| 158 | |
| 159 | // Owned path: refresh by the persisted entry id. |
| 160 | if (output?.trustedDomainId) { |
| 161 | const observed = yield* getTrustedDomain(acct, output.trustedDomainId); |
| 162 | if (observed) return toAttributes(observed, acct); |
| 163 | } |
| 164 | |
| 165 | // Cold lookup: recover by pattern; matches are reported as `Unowned` |
| 166 | // so takeover is gated behind the adopt policy. |
| 167 | const pattern = output?.pattern ?? olds?.pattern; |
| 168 | if (pattern !== undefined) { |
| 169 | const observed = yield* findByPattern(acct, pattern); |
| 170 | if (observed) return Unowned(toAttributes(observed, acct)); |
| 171 | } |
| 172 | return undefined; |
| 173 | }), |
| 174 | |
| 175 | reconcile: Effect.fn(function* ({ news, output }) { |
| 176 | const { accountId } = yield* yield* CloudflareEnvironment; |
| 177 | |
| 178 | // 1. Observe — id hint first, then pattern scan. |
| 179 | let observed = output?.trustedDomainId |
| 180 | ? yield* getTrustedDomain(accountId, output.trustedDomainId) |
| 181 | : undefined; |
| 182 | if (!observed) { |
| 183 | observed = yield* findByPattern(accountId, news.pattern); |
| 184 | } |
| 185 | |
| 186 | // 2. Ensure — create when missing. |
| 187 | if (!observed) { |
| 188 | const created = yield* emailSecurity.createSettingTrustedDomain({ |
| 189 | accountId, |
| 190 | pattern: news.pattern, |
| 191 | isRecent: news.isRecent ?? false, |
| 192 | isSimilarity: news.isSimilarity ?? false, |
| 193 | isRegex: news.isRegex ?? false, |
| 194 | comments: news.comments, |
| 195 | }); |
| 196 | return toAttributes(created, accountId); |
| 197 | } |
| 198 | |
| 199 | // 3. Sync — patch only on a delta. |
| 200 | const dirty = |
| 201 | (observed.pattern ?? "") !== news.pattern || |
| 202 | (observed.isRecent ?? false) !== (news.isRecent ?? false) || |
| 203 | (observed.isSimilarity ?? false) !== (news.isSimilarity ?? false) || |
| 204 | (observed.isRegex ?? false) !== (news.isRegex ?? false) || |
| 205 | (news.comments !== undefined && |
| 206 | (observed.comments ?? "") !== news.comments); |
| 207 | if (!dirty) { |
| 208 | return toAttributes(observed, accountId); |
| 209 | } |
| 210 | |
| 211 | const patched = yield* emailSecurity.patchSettingTrustedDomain({ |
| 212 | accountId, |
| 213 | trustedDomainId: observed.id ?? "", |
| 214 | pattern: news.pattern, |
| 215 | isRecent: news.isRecent ?? false, |
| 216 | isSimilarity: news.isSimilarity ?? false, |
| 217 | isRegex: news.isRegex ?? false, |
| 218 | comments: news.comments, |
| 219 | }); |
| 220 | return toAttributes(patched, accountId); |
| 221 | }), |
| 222 | |
| 223 | delete: Effect.fn(function* ({ output }) { |
| 224 | yield* emailSecurity |
| 225 | .deleteSettingTrustedDomain({ |
| 226 | accountId: output.accountId, |
| 227 | trustedDomainId: output.trustedDomainId, |
| 228 | }) |
| 229 | .pipe(Effect.catchTag("TrustedDomainNotFound", () => Effect.void)); |
| 230 | }), |
| 231 | }); |
| 232 | |
| 233 | type ObservedTrustedDomain = emailSecurity.GetSettingTrustedDomainResponse; |
| 234 | |
| 235 | /** |
| 236 | * Read a trusted domain by id, mapping "gone" (`TrustedDomainNotFound`, |
| 237 | * HTTP 404) to `undefined`. |
| 238 | */ |
| 239 | const getTrustedDomain = (accountId: string, trustedDomainId: string) => |
| 240 | emailSecurity.getSettingTrustedDomain({ accountId, trustedDomainId }).pipe( |
| 241 | Effect.map((entry): ObservedTrustedDomain | undefined => entry), |
| 242 | Effect.catchTag("TrustedDomainNotFound", () => Effect.succeed(undefined)), |
| 243 | ); |
| 244 | |
| 245 | /** |
| 246 | * Find a trusted domain by exact pattern. The `pattern` query filter is a |
| 247 | * server-side hint; the exact match is re-checked client-side. Picks the |
| 248 | * oldest match for determinism. |
| 249 | */ |
| 250 | const findByPattern = (accountId: string, pattern: string) => |
| 251 | emailSecurity.listSettingTrustedDomains.items({ accountId, pattern }).pipe( |
| 252 | Stream.runCollect, |
| 253 | Effect.map((chunk) => |
| 254 | Array.from(chunk) |
| 255 | .filter((entry) => entry.pattern === pattern) |
| 256 | .sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? "")) |
| 257 | .at(0), |
| 258 | ), |
| 259 | ); |
| 260 | |
| 261 | const toAttributes = ( |
| 262 | entry: |
| 263 | | ObservedTrustedDomain |
| 264 | | emailSecurity.CreateSettingTrustedDomainResponse |
| 265 | | emailSecurity.PatchSettingTrustedDomainResponse |
| 266 | | emailSecurity.ListSettingTrustedDomainsResponse["result"][number], |
| 267 | accountId: string, |
| 268 | ): TrustedDomainAttributes => ({ |
| 269 | trustedDomainId: entry.id ?? "", |
| 270 | accountId, |
| 271 | pattern: entry.pattern ?? "", |
| 272 | isRecent: entry.isRecent ?? false, |
| 273 | isSimilarity: entry.isSimilarity ?? false, |
| 274 | isRegex: entry.isRegex ?? false, |
| 275 | comments: entry.comments ?? undefined, |
| 276 | createdAt: entry.createdAt ?? "", |
| 277 | modifiedAt: entry.modifiedAt ?? undefined, |
| 278 | }); |