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 { isResolved } from "../../Diff.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
11import type { Providers } from "../Providers.ts";
12
13const EmailSecurityDomainTypeId = "Cloudflare.Email.Domain" as const;
14type EmailSecurityDomainTypeId = typeof EmailSecurityDomainTypeId;
15
16/**
17 * Delivery modes a domain accepts messages through.
18 */
19export type DeliveryMode = "DIRECT" | "BCC" | "JOURNAL" | "API" | "RETRO_SCAN";
20
21/**
22 * Message dispositions that can be dropped before delivery.
23 */
24export type DropDisposition =
25 | "MALICIOUS"
26 | "MALICIOUS-BEC"
27 | "SUSPICIOUS"
28 | "SPOOF"
29 | "SPAM"
30 | "BULK"
31 | "ENCRYPTED"
32 | "EXTERNAL"
33 | "UNKNOWN"
34 | "NONE";
35
36export interface DomainProps {
37 /**
38 * The fully-qualified domain name of a domain that is **already
39 * onboarded** to Email Security (via MX/BCC/journal or an API
40 * integration). Domains cannot be created via the API — onboarding
41 * happens in the Email Security dashboard. The domain name is the
42 * resource's identity — changing it triggers a replacement (re-adopting
43 * a different domain).
44 */
45 domain: string;
46 /**
47 * Delivery modes the domain accepts messages through.
48 */
49 allowedDeliveryModes?: DeliveryMode[];
50 /**
51 * Message dispositions that are dropped before delivery.
52 */
53 dropDispositions?: DropDisposition[];
54 /**
55 * IP/CIDR restrictions for inbound delivery.
56 */
57 ipRestrictions?: string[];
58 /**
59 * Folder messages are scanned in for API-deployed domains.
60 */
61 folder?: "AllItems" | "Inbox";
62 /**
63 * The API integration (e.g. Office 365) the domain is associated with.
64 */
65 integrationId?: string;
66 /**
67 * Number of message hops to look back when determining the original
68 * sender.
69 */
70 lookbackHops?: number;
71 /**
72 * Require TLS for inbound mail.
73 */
74 requireTlsInbound?: boolean;
75 /**
76 * Require TLS for outbound (onward) delivery.
77 */
78 requireTlsOutbound?: boolean;
79 /**
80 * Onward delivery host for the domain.
81 */
82 transport?: string;
83}
84
85export interface DomainAttributes {
86 /** Cloudflare-assigned domain identifier. */
87 domainId: string;
88 /** The account the domain belongs to. */
89 accountId: string;
90 /** The fully-qualified domain name. */
91 domain: string;
92 /** Domain authorization status, if reported. */
93 authorization: { authorized: boolean; timestamp: string } | undefined;
94 /** Delivery modes the domain accepts messages through. */
95 allowedDeliveryModes: DeliveryMode[];
96 /** Message dispositions dropped before delivery. */
97 dropDispositions: DropDisposition[];
98 /** IP/CIDR restrictions for inbound delivery. */
99 ipRestrictions: string[];
100 /** Folder messages are scanned in for API-deployed domains. */
101 folder: "AllItems" | "Inbox" | undefined;
102 /** The API integration the domain is associated with, if any. */
103 integrationId: string | undefined;
104 /** Number of lookback hops, if configured. */
105 lookbackHops: number | undefined;
106 /** Whether TLS is required for inbound mail. */
107 requireTlsInbound: boolean | undefined;
108 /** Whether TLS is required for outbound delivery. */
109 requireTlsOutbound: boolean | undefined;
110 /** Onward delivery host. */
111 transport: string;
112 /** Office 365 tenant id for API-deployed domains, if any. */
113 o365TenantId: string | undefined;
114 /** Regions the domain's mail is processed in. */
115 regions: string[];
116 /** ISO8601 creation timestamp. */
117 createdAt: string;
118 /** ISO8601 last-modified timestamp, if the domain has been modified. */
119 modifiedAt: string | undefined;
120}
121
122export type Domain = Resource<
123 EmailSecurityDomainTypeId,
124 DomainProps,
125 DomainAttributes,
126 never,
127 Providers
128>;
129
130/**
131 * A Cloudflare Email Security (Area 1) domain's settings.
132 *
133 * Domains cannot be created via the API — they appear when the domain is
134 * onboarded to Email Security (MX/BCC/journal or an API integration) in
135 * the dashboard. This resource **adopts and configures** an existing
136 * domain: `read` finds it by name and reports it as unowned, so taking it
137 * under management is gated behind `--adopt` (or `adopt(true)`).
138 *
139 * :::warning
140 * **Destroying this resource offboards the domain from Email Security**
141 * (the underlying API call is `DELETE .../settings/domains/{id}`). Mail
142 * flow for the domain is no longer scanned afterwards. Plan destroys with
143 * care.
144 * :::
145 *
146 * Requires the Email Security enterprise add-on; accounts without the
147 * entitlement receive the typed `EmailSecurityNotEntitled` error.
148 * ### Configuring a Domain
149 * **Example:** Drop malicious mail before delivery
150 * ```typescript
151 * yield* Cloudflare.Email.Domain("MailDomain", {
152 * domain: "example.com",
153 * dropDispositions: ["MALICIOUS", "SPOOF"],
154 * });
155 * ```
156 *
157 * **Example:** Restrict inbound delivery and require TLS
158 * ```typescript
159 * yield* Cloudflare.Email.Domain("MailDomain", {
160 * domain: "example.com",
161 * ipRestrictions: ["203.0.113.0/24"],
162 * requireTlsInbound: true,
163 * requireTlsOutbound: true,
164 * transport: "mx.example.com",
165 * });
166 * ```
167 *
168 * @see https://developers.cloudflare.com/cloudflare-one/email-security/
169 *
170 * @resource
171 * @product Email Security
172 * @category Email
173 */
174export const Domain = Resource<Domain>(EmailSecurityDomainTypeId, {
175 aliases: ["Cloudflare.EmailSecurity.Domain"],
176});
177
178/**
179 * Returns true if the given value is an Domain resource.
180 */
181export const isDomain = (value: unknown): value is Domain =>
182 Predicate.hasProperty(value, "Type") &&
183 value.Type === EmailSecurityDomainTypeId;
184
185export const DomainProvider = () =>
186 Provider.succeed(Domain, {
187 stables: ["domainId", "accountId", "domain", "o365TenantId", "createdAt"],
188
189 diff: Effect.fn(function* ({ olds, news, output }) {
190 if (!isResolved(news)) return undefined;
191 // The domain name is the resource's identity — changing it means
192 // re-adopting a different onboarded domain.
193 const oldDomain = output?.domain ?? olds?.domain;
194 if (oldDomain !== undefined && oldDomain !== news.domain) {
195 return { action: "replace" } as const;
196 }
197 return undefined;
198 }),
199
200 read: Effect.fn(function* ({ output, olds }) {
201 const { accountId } = yield* yield* CloudflareEnvironment;
202 const acct = output?.accountId ?? accountId;
203
204 // Owned path: refresh by the persisted domain id.
205 if (output?.domainId) {
206 const observed = yield* getDomain(acct, output.domainId);
207 if (observed) return toAttributes(observed, acct);
208 }
209
210 // Cold lookup: the domain pre-exists (onboarded in the dashboard) and
211 // carries no ownership markers — report it `Unowned` so taking it
212 // under management is gated behind the adopt policy.
213 const domain = output?.domain ?? olds?.domain;
214 if (domain !== undefined) {
215 const observed = yield* findByName(acct, domain);
216 if (observed) return Unowned(toAttributes(observed, acct));
217 }
218 return undefined;
219 }),
220
221 reconcile: Effect.fn(function* ({ news, output }) {
222 const { accountId } = yield* yield* CloudflareEnvironment;
223
224 // 1. Observe — the domain must already be onboarded; the API cannot
225 // create one.
226 let observed = output?.domainId
227 ? yield* getDomain(accountId, output.domainId)
228 : undefined;
229 if (!observed) {
230 observed = yield* findByName(accountId, news.domain);
231 }
232 if (!observed) {
233 return yield* Effect.fail(
234 new Error(
235 `Domain "${news.domain}" is not onboarded to Email Security on ` +
236 `account ${accountId}. Cloudflare.Email.Domain only ` +
237 `manages settings on an existing domain — onboard the domain ` +
238 `in the Email Security dashboard first.`,
239 ),
240 );
241 }
242
243 // 2. Sync — diff observed settings against the declared props and
244 // patch only on a delta. Omitted props are left untouched.
245 const delta = settingsDelta(observed, news);
246 if (delta === undefined) {
247 return toAttributes(observed, accountId);
248 }
249 const patched = yield* emailSecurity.patchSettingDomain({
250 accountId,
251 domainId: observed.id ?? "",
252 ...delta,
253 });
254 return toAttributes(patched, accountId);
255 }),
256
257 delete: Effect.fn(function* ({ output }) {
258 // DESTRUCTIVE: this offboards the domain from Email Security — its
259 // mail flow is no longer scanned. Already-gone is success.
260 yield* emailSecurity
261 .deleteSettingDomain({
262 accountId: output.accountId,
263 domainId: output.domainId,
264 })
265 .pipe(
266 Effect.catchTag("EmailSecurityDomainNotFound", () => Effect.void),
267 );
268 }),
269
270 // Account-scoped collection: enumerate every onboarded domain via the
271 // paginated account-level list API and hydrate each into the exact
272 // `read` Attributes shape. Accounts without the Email Security add-on
273 // (`EmailSecurityNotEntitled`) or lacking access (`Forbidden`) have
274 // nothing to enumerate — return [].
275 list: Effect.fn(function* () {
276 const { accountId } = yield* yield* CloudflareEnvironment;
277 return yield* emailSecurity.listSettingDomains.pages({ accountId }).pipe(
278 Stream.runCollect,
279 Effect.map((chunk) =>
280 Array.from(chunk).flatMap((page) =>
281 (page.result ?? []).map((d) =>
282 toAttributes(d as ObservedDomain, accountId),
283 ),
284 ),
285 ),
286 Effect.catchTag(["EmailSecurityNotEntitled", "Forbidden"], () =>
287 Effect.succeed([] as DomainAttributes[]),
288 ),
289 );
290 }),
291 });
292
293type ObservedDomain = emailSecurity.GetSettingDomainResponse;
294
295/**
296 * Read a domain by id, mapping "gone" (`EmailSecurityDomainNotFound`,
297 * HTTP 404) to `undefined`.
298 */
299const getDomain = (accountId: string, domainId: string) =>
300 emailSecurity.getSettingDomain({ accountId, domainId }).pipe(
301 Effect.map((domain): ObservedDomain | undefined => domain),
302 Effect.catchTag("EmailSecurityDomainNotFound", () =>
303 Effect.succeed(undefined),
304 ),
305 );
306
307/**
308 * Find an onboarded domain by exact name.
309 */
310const findByName = (accountId: string, domain: string) =>
311 emailSecurity.listSettingDomains.items({ accountId, domain: [domain] }).pipe(
312 Stream.runCollect,
313 Effect.map((chunk) =>
314 Array.from(chunk).find((d): d is ObservedDomain => d.domain === domain),
315 ),
316 );
317
318const sameArray = (
319 observed: readonly string[] | null | undefined,
320 desired: readonly string[],
321) => {
322 const a = [...(observed ?? [])].sort();
323 const b = [...desired].sort();
324 return a.length === b.length && a.join(",") === b.join(",");
325};
326
327/**
328 * Diff observed domain settings against desired props. Only fields that
329 * are explicitly declared (not `undefined`) participate. Returns
330 * `undefined` when nothing needs to change, so the PATCH can be skipped
331 * entirely. Inputs have been resolved to concrete values by Plan.
332 */
333const settingsDelta = (
334 observed: ObservedDomain,
335 news: DomainProps,
336):
337 | Omit<emailSecurity.PatchSettingDomainRequest, "accountId" | "domainId">
338 | undefined => {
339 const delta: Omit<
340 emailSecurity.PatchSettingDomainRequest,
341 "accountId" | "domainId"
342 > = {};
343 let dirty = false;
344 if (
345 news.allowedDeliveryModes !== undefined &&
346 !sameArray(observed.allowedDeliveryModes, news.allowedDeliveryModes)
347 ) {
348 delta.allowedDeliveryModes = news.allowedDeliveryModes;
349 dirty = true;
350 }
351 if (
352 news.dropDispositions !== undefined &&
353 !sameArray(observed.dropDispositions, news.dropDispositions)
354 ) {
355 delta.dropDispositions = news.dropDispositions;
356 dirty = true;
357 }
358 if (
359 news.ipRestrictions !== undefined &&
360 !sameArray(observed.ipRestrictions, news.ipRestrictions)
361 ) {
362 delta.ipRestrictions = news.ipRestrictions;
363 dirty = true;
364 }
365 if (news.folder !== undefined && (observed.folder ?? "") !== news.folder) {
366 delta.folder = news.folder;
367 dirty = true;
368 }
369 const integrationId = news.integrationId as string | undefined;
370 if (
371 integrationId !== undefined &&
372 (observed.integrationId ?? "") !== integrationId
373 ) {
374 delta.integrationId = integrationId;
375 dirty = true;
376 }
377 if (
378 news.lookbackHops !== undefined &&
379 observed.lookbackHops !== news.lookbackHops
380 ) {
381 delta.lookbackHops = news.lookbackHops;
382 dirty = true;
383 }
384 if (
385 news.requireTlsInbound !== undefined &&
386 (observed.requireTlsInbound ?? false) !== news.requireTlsInbound
387 ) {
388 delta.requireTlsInbound = news.requireTlsInbound;
389 dirty = true;
390 }
391 if (
392 news.requireTlsOutbound !== undefined &&
393 (observed.requireTlsOutbound ?? false) !== news.requireTlsOutbound
394 ) {
395 delta.requireTlsOutbound = news.requireTlsOutbound;
396 dirty = true;
397 }
398 if (
399 news.transport !== undefined &&
400 (observed.transport ?? "") !== news.transport
401 ) {
402 delta.transport = news.transport;
403 dirty = true;
404 }
405 return dirty ? delta : undefined;
406};
407
408const toAttributes = (
409 domain: ObservedDomain | emailSecurity.PatchSettingDomainResponse,
410 accountId: string,
411): DomainAttributes => ({
412 domainId: domain.id ?? "",
413 accountId,
414 domain: domain.domain ?? "",
415 authorization: domain.authorization
416 ? {
417 authorized: domain.authorization.authorized,
418 timestamp: domain.authorization.timestamp,
419 }
420 : undefined,
421 allowedDeliveryModes: [
422 ...(domain.allowedDeliveryModes ?? []),
423 ] as DeliveryMode[],
424 dropDispositions: [...(domain.dropDispositions ?? [])] as DropDisposition[],
425 ipRestrictions: [...(domain.ipRestrictions ?? [])],
426 folder: (domain.folder ?? undefined) as "AllItems" | "Inbox" | undefined,
427 integrationId: domain.integrationId ?? undefined,
428 lookbackHops: domain.lookbackHops ?? undefined,
429 requireTlsInbound: domain.requireTlsInbound ?? undefined,
430 requireTlsOutbound: domain.requireTlsOutbound ?? undefined,
431 transport: domain.transport ?? "",
432 o365TenantId: domain.o365TenantId ?? undefined,
433 regions: [...(domain.regions ?? [])],
434 createdAt: domain.createdAt ?? "",
435 modifiedAt: domain.modifiedAt ?? undefined,
436});