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 EmailSecurityAllowPolicyTypeId = "Cloudflare.Email.AllowPolicy" as const;
13type EmailSecurityAllowPolicyTypeId = typeof EmailSecurityAllowPolicyTypeId;
14
15/**
16 * Type of pattern matching for Email Security sender/recipient patterns.
17 * `UNKNOWN` is deprecated and rejected on create/update, so it is not
18 * accepted as an input.
19 */
20export type PatternType = "EMAIL" | "DOMAIN" | "IP";
21
22export interface AllowPolicyProps {
23 /**
24 * The email address, domain, IP, or regular expression to match.
25 * The pattern is the policy's identity for cold-state recovery — a
26 * pre-existing policy with the same pattern is reported as unowned and
27 * only taken over when adoption is enabled.
28 */
29 pattern: string;
30 /**
31 * Type of pattern matching.
32 */
33 patternType: PatternType;
34 /**
35 * Whether `pattern` is a regular expression.
36 * @default false
37 */
38 isRegex?: boolean;
39 /**
40 * Messages from this sender are exempted from Spam, Spoof, and Bulk
41 * dispositions. Does not exempt Malicious or Suspicious dispositions.
42 * @default false
43 */
44 isAcceptableSender?: boolean;
45 /**
46 * Messages to this recipient bypass all detections.
47 * @default false
48 */
49 isExemptRecipient?: boolean;
50 /**
51 * Messages from this sender bypass all detections and link following.
52 * @default false
53 */
54 isTrustedSender?: boolean;
55 /**
56 * Enforce DMARC, SPF, or DKIM authentication — when on, Email Security
57 * only honors the policy when the message passes authentication.
58 * @default true
59 */
60 verifySender?: boolean;
61 /**
62 * Free-form notes about the policy.
63 */
64 comments?: string;
65}
66
67export interface AllowPolicyAttributes {
68 /** Cloudflare-assigned allow policy identifier. */
69 policyId: string;
70 /** The account the policy belongs to. */
71 accountId: string;
72 /** The matched pattern. */
73 pattern: string;
74 /** Type of pattern matching. */
75 patternType: PatternType;
76 /** Whether the pattern is a regular expression. */
77 isRegex: boolean;
78 /** Whether the sender is exempted from Spam/Spoof/Bulk dispositions. */
79 isAcceptableSender: boolean;
80 /** Whether messages to the recipient bypass all detections. */
81 isExemptRecipient: boolean;
82 /** Whether the sender bypasses all detections and link following. */
83 isTrustedSender: boolean;
84 /** Whether sender authentication (SPF/DKIM/DMARC) is enforced. */
85 verifySender: boolean;
86 /** Free-form notes about the policy, if set. */
87 comments: string | undefined;
88 /** ISO8601 creation timestamp. */
89 createdAt: string;
90 /** ISO8601 last-modified timestamp, if the policy has been modified. */
91 modifiedAt: string | undefined;
92}
93
94export type AllowPolicy = Resource<
95 EmailSecurityAllowPolicyTypeId,
96 AllowPolicyProps,
97 AllowPolicyAttributes,
98 never,
99 Providers
100>;
101
102/**
103 * A Cloudflare Email Security (Area 1) allow policy — exempts messages
104 * matching a sender/recipient pattern from detections.
105 *
106 * All fields are mutable in place. Requires the Email Security enterprise
107 * add-on; accounts without the entitlement receive the typed
108 * `EmailSecurityNotEntitled` error.
109 * ### Creating an Allow Policy
110 * **Example:** Acceptable sender by email address
111 * ```typescript
112 * yield* Cloudflare.Email.AllowPolicy("NewsletterSender", {
113 * pattern: "news@partner.example.com",
114 * patternType: "EMAIL",
115 * isAcceptableSender: true,
116 * });
117 * ```
118 *
119 * **Example:** Trusted sender domain (bypasses all detections)
120 * ```typescript
121 * yield* Cloudflare.Email.AllowPolicy("TrustedPartner", {
122 * pattern: "partner.example.com",
123 * patternType: "DOMAIN",
124 * isTrustedSender: true,
125 * comments: "contractually trusted partner",
126 * });
127 * ```
128 *
129 * **Example:** Exempt recipient
130 * ```typescript
131 * // Messages delivered to the abuse mailbox must never be filtered.
132 * yield* Cloudflare.Email.AllowPolicy("AbuseMailbox", {
133 * pattern: "abuse@example.com",
134 * patternType: "EMAIL",
135 * isExemptRecipient: true,
136 * verifySender: false,
137 * });
138 * ```
139 *
140 * @see https://developers.cloudflare.com/cloudflare-one/email-security/
141 *
142 * @resource
143 * @product Email Security
144 * @category Email
145 */
146export const AllowPolicy = Resource<AllowPolicy>(
147 EmailSecurityAllowPolicyTypeId,
148 { aliases: ["Cloudflare.EmailSecurity.AllowPolicy"] },
149);
150
151/**
152 * Returns true if the given value is an AllowPolicy resource.
153 */
154export const isAllowPolicy = (value: unknown): value is AllowPolicy =>
155 Predicate.hasProperty(value, "Type") &&
156 value.Type === EmailSecurityAllowPolicyTypeId;
157
158export const AllowPolicyProvider = () =>
159 Provider.succeed(AllowPolicy, {
160 stables: ["policyId", "accountId", "createdAt"],
161
162 list: Effect.fn(function* () {
163 const { accountId } = yield* yield* CloudflareEnvironment;
164 return yield* emailSecurity.listSettingAllowPolicies
165 .pages({ accountId })
166 .pipe(
167 Stream.runCollect,
168 Effect.map((chunk) =>
169 Array.from(chunk).flatMap((page) =>
170 (page.result ?? []).map((policy) =>
171 toAttributes(policy, accountId),
172 ),
173 ),
174 ),
175 // Email Security is a paid add-on gated by both account
176 // entitlement and token scope: an unentitled account answers
177 // `EmailSecurityNotEntitled`, while a credential lacking the
178 // Email Security scope (e.g. Cloudflare OAuth) answers a bare
179 // `Forbidden`. Neither can enumerate, so both mean "none
180 // visible" — matching `Domain.list()`. Returning `[]` is the
181 // safe direction for the callers of `list` (orphan detection
182 // never deletes what it cannot see).
183 Effect.catchTag(["EmailSecurityNotEntitled", "Forbidden"], () =>
184 Effect.succeed([] as AllowPolicyAttributes[]),
185 ),
186 );
187 }),
188
189 read: Effect.fn(function* ({ output, olds }) {
190 const { accountId } = yield* yield* CloudflareEnvironment;
191 const acct = output?.accountId ?? accountId;
192
193 // Owned path: refresh by the persisted policy id.
194 if (output?.policyId) {
195 const observed = yield* getPolicy(acct, output.policyId);
196 if (observed) return toAttributes(observed, acct);
197 }
198
199 // Cold lookup: recover by pattern. Allow policies carry no ownership
200 // markers, so a match is reported as `Unowned` and only taken over
201 // when adoption is enabled.
202 const pattern = output?.pattern ?? olds?.pattern;
203 if (pattern !== undefined) {
204 const observed = yield* findByPattern(acct, pattern);
205 if (observed) return Unowned(toAttributes(observed, acct));
206 }
207 return undefined;
208 }),
209
210 reconcile: Effect.fn(function* ({ news, output }) {
211 const { accountId } = yield* yield* CloudflareEnvironment;
212 const desired = withDefaults(news);
213
214 // 1. Observe — the id on `output` is a hint, not a guarantee; a
215 // missing policy falls through to the pattern scan and create.
216 let observed = output?.policyId
217 ? yield* getPolicy(accountId, output.policyId)
218 : undefined;
219 if (!observed) {
220 observed = yield* findByPattern(accountId, news.pattern);
221 }
222
223 // 2. Ensure — create when missing.
224 if (!observed) {
225 const created = yield* emailSecurity.createSettingAllowPolicy({
226 accountId,
227 ...desired,
228 });
229 return toAttributes(created, accountId);
230 }
231
232 // 3. Sync — diff observed cloud state against desired and patch only
233 // on a delta.
234 const dirty =
235 (observed.pattern ?? "") !== desired.pattern ||
236 (observed.patternType ?? "") !== desired.patternType ||
237 (observed.isRegex ?? false) !== desired.isRegex ||
238 (observed.isAcceptableSender ?? false) !== desired.isAcceptableSender ||
239 (observed.isExemptRecipient ?? false) !== desired.isExemptRecipient ||
240 (observed.isTrustedSender ?? false) !== desired.isTrustedSender ||
241 (observed.verifySender ?? false) !== desired.verifySender ||
242 (news.comments !== undefined &&
243 (observed.comments ?? "") !== news.comments);
244 if (!dirty) {
245 return toAttributes(observed, accountId);
246 }
247
248 const patched = yield* emailSecurity.patchSettingAllowPolicy({
249 accountId,
250 policyId: observed.id,
251 ...desired,
252 });
253 return toAttributes(patched, accountId);
254 }),
255
256 delete: Effect.fn(function* ({ output }) {
257 yield* emailSecurity
258 .deleteSettingAllowPolicy({
259 accountId: output.accountId,
260 policyId: output.policyId,
261 })
262 .pipe(Effect.catchTag("AllowPolicyNotFound", () => Effect.void));
263 }),
264 });
265
266type ObservedAllowPolicy = emailSecurity.GetSettingAllowPolicyResponse;
267
268/**
269 * Read an allow policy by id, mapping "gone" (`AllowPolicyNotFound`,
270 * HTTP 404) to `undefined`.
271 */
272const getPolicy = (accountId: string, policyId: string) =>
273 emailSecurity.getSettingAllowPolicy({ accountId, policyId }).pipe(
274 Effect.map((policy): ObservedAllowPolicy | undefined => policy),
275 Effect.catchTag("AllowPolicyNotFound", () => Effect.succeed(undefined)),
276 );
277
278/**
279 * Find an allow policy by exact pattern. The `pattern` query filter is a
280 * server-side hint; the exact match is re-checked client-side. Picks the
281 * oldest match for determinism.
282 */
283const findByPattern = (accountId: string, pattern: string) =>
284 emailSecurity.listSettingAllowPolicies.items({ accountId, pattern }).pipe(
285 Stream.runCollect,
286 Effect.map((chunk) =>
287 Array.from(chunk)
288 .filter((p) => p.pattern === pattern)
289 .sort((a, b) => a.createdAt.localeCompare(b.createdAt))
290 .at(0),
291 ),
292 );
293
294const withDefaults = (news: AllowPolicyProps) => ({
295 pattern: news.pattern,
296 patternType: news.patternType,
297 isRegex: news.isRegex ?? false,
298 isAcceptableSender: news.isAcceptableSender ?? false,
299 isExemptRecipient: news.isExemptRecipient ?? false,
300 isTrustedSender: news.isTrustedSender ?? false,
301 verifySender: news.verifySender ?? true,
302 comments: news.comments,
303});
304
305const toAttributes = (
306 policy:
307 | ObservedAllowPolicy
308 | emailSecurity.CreateSettingAllowPolicyResponse
309 | emailSecurity.PatchSettingAllowPolicyResponse,
310 accountId: string,
311): AllowPolicyAttributes => ({
312 policyId: policy.id,
313 accountId,
314 pattern: policy.pattern ?? "",
315 // UNKNOWN can only appear on legacy entries; new policies always echo
316 // the type they were created with.
317 patternType: (policy.patternType ?? "EMAIL") as PatternType,
318 isRegex: policy.isRegex ?? false,
319 isAcceptableSender: policy.isAcceptableSender ?? false,
320 isExemptRecipient: policy.isExemptRecipient ?? false,
321 isTrustedSender: policy.isTrustedSender ?? false,
322 verifySender: policy.verifySender ?? false,
323 comments: policy.comments ?? undefined,
324 createdAt: policy.createdAt,
325 modifiedAt: policy.modifiedAt ?? undefined,
326});