1import * as emailSecurity from "@distilled.cloud/cloudflare/email-security";
2import * as Effect from "effect/Effect";
3import * as Predicate from "effect/Predicate";
4import * as Stream from "effect/Stream";
5
6import { Unowned } from "../../AdoptPolicy.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
10import type { Providers } from "../Providers.ts";
11
12const EmailSecurityTrustedDomainTypeId =
13 "Cloudflare.Email.TrustedDomain" as const;
14type EmailSecurityTrustedDomainTypeId = typeof EmailSecurityTrustedDomainTypeId;
15
16export 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
48export 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
69export 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 */
108export 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 */
116export const isTrustedDomain = (value: unknown): value is TrustedDomain =>
117 Predicate.hasProperty(value, "Type") &&
118 value.Type === EmailSecurityTrustedDomainTypeId;
119
120export 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
233type ObservedTrustedDomain = emailSecurity.GetSettingTrustedDomainResponse;
234
235/**
236 * Read a trusted domain by id, mapping "gone" (`TrustedDomainNotFound`,
237 * HTTP 404) to `undefined`.
238 */
239const 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 */
250const 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
261const 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});