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 EmailSecurityImpersonationRegistryEntryTypeId =
13 "Cloudflare.Email.ImpersonationRegistryEntry" as const;
14type EmailSecurityImpersonationRegistryEntryTypeId =
15 typeof EmailSecurityImpersonationRegistryEntryTypeId;
16
17export interface ImpersonationRegistryEntryProps {
18 /**
19 * The display name to protect (e.g. a VIP's name as it appears in the
20 * `From` header). Together with `email` it forms the entry's identity
21 * for cold-state recovery.
22 */
23 name: string;
24 /**
25 * The legitimate email address (or regular expression) for the display
26 * name. Messages using the display name from a different address are
27 * flagged as impersonation/BEC.
28 */
29 email: string;
30 /**
31 * Whether `email` is a regular expression.
32 * @default false
33 */
34 isEmailRegex?: boolean;
35 /**
36 * Free-form notes about the entry.
37 */
38 comments?: string;
39}
40
41export interface ImpersonationRegistryEntryAttributes {
42 /** Cloudflare-assigned impersonation registry entry identifier. */
43 entryId: string;
44 /** The account the entry belongs to. */
45 accountId: string;
46 /** The protected display name. */
47 name: string;
48 /** The legitimate email address (or regex). */
49 email: string;
50 /** Whether the email is a regular expression. */
51 isEmailRegex: boolean;
52 /** Free-form notes about the entry, if set. */
53 comments: string | undefined;
54 /**
55 * Where the entry came from. Manually created entries are
56 * `A1S_INTERNAL`; directory-synced entries carry the integration's
57 * provenance.
58 */
59 provenance: string | undefined;
60 /** ISO8601 creation timestamp. */
61 createdAt: string;
62 /** ISO8601 last-modified timestamp, if the entry has been modified. */
63 modifiedAt: string | undefined;
64}
65
66export type ImpersonationRegistryEntry = Resource<
67 EmailSecurityImpersonationRegistryEntryTypeId,
68 ImpersonationRegistryEntryProps,
69 ImpersonationRegistryEntryAttributes,
70 never,
71 Providers
72>;
73
74/**
75 * A Cloudflare Email Security (Area 1) impersonation registry entry —
76 * maps a protected display name (e.g. a VIP) to their legitimate email
77 * address for BEC/impersonation detection.
78 *
79 * All fields are mutable in place. Directory-synced fields
80 * (`directory_id`, `directory_node_id`, `provenance`) are managed by
81 * Office365/Google integrations and are not exposed as inputs. Requires
82 * the Email Security enterprise add-on; accounts without the entitlement
83 * receive the typed `EmailSecurityNotEntitled` error.
84 * ### Registering Protected Identities
85 * **Example:** Protect an executive's display name
86 * ```typescript
87 * yield* Cloudflare.Email.ImpersonationRegistryEntry("Ceo", {
88 * name: "Jane Smith",
89 * email: "jane.smith@example.com",
90 * comments: "CEO — high-value BEC target",
91 * });
92 * ```
93 *
94 * **Example:** Match several legitimate addresses with a regex
95 * ```typescript
96 * yield* Cloudflare.Email.ImpersonationRegistryEntry("Finance", {
97 * name: "Accounts Payable",
98 * email: "^ap(-[a-z]+)?@example\\.com$",
99 * isEmailRegex: true,
100 * });
101 * ```
102 *
103 * @see https://developers.cloudflare.com/cloudflare-one/email-security/
104 *
105 * @resource
106 * @product Email Security
107 * @category Email
108 */
109export const ImpersonationRegistryEntry = Resource<ImpersonationRegistryEntry>(
110 EmailSecurityImpersonationRegistryEntryTypeId,
111 { aliases: ["Cloudflare.EmailSecurity.ImpersonationRegistryEntry"] },
112);
113
114/**
115 * Returns true if the given value is an
116 * ImpersonationRegistryEntry resource.
117 */
118export const isImpersonationRegistryEntry = (
119 value: unknown,
120): value is ImpersonationRegistryEntry =>
121 Predicate.hasProperty(value, "Type") &&
122 value.Type === EmailSecurityImpersonationRegistryEntryTypeId;
123
124export const ImpersonationRegistryEntryProvider = () =>
125 Provider.succeed(ImpersonationRegistryEntry, {
126 stables: ["entryId", "accountId", "createdAt"],
127
128 // Account collection: exhaustively paginate the account-scoped registry
129 // list and hydrate each row into the exact `read` Attributes shape.
130 // Accounts without the Email Security add-on return the typed
131 // `EmailSecurityNotEntitled` error → treat as an empty registry.
132 list: Effect.fn(function* () {
133 const { accountId } = yield* yield* CloudflareEnvironment;
134 return yield* emailSecurity.listSettingImpersonationRegistries
135 .pages({ accountId })
136 .pipe(
137 Stream.runCollect,
138 Effect.map((chunk) =>
139 Array.from(chunk).flatMap((page) =>
140 (page.result ?? []).map((entry) =>
141 toAttributes(entry, accountId),
142 ),
143 ),
144 ),
145 // Email Security is a paid add-on gated by both account
146 // entitlement and token scope: an unentitled account answers
147 // `EmailSecurityNotEntitled`, while a credential lacking the
148 // Email Security scope (e.g. Cloudflare OAuth) answers a bare
149 // `Forbidden`. Neither can enumerate, so both mean "none
150 // visible" — matching `Domain.list()`. Returning `[]` is the
151 // safe direction for the callers of `list` (orphan detection
152 // never deletes what it cannot see).
153 Effect.catchTag(["EmailSecurityNotEntitled", "Forbidden"], () =>
154 Effect.succeed([] as ImpersonationRegistryEntryAttributes[]),
155 ),
156 );
157 }),
158
159 read: Effect.fn(function* ({ output, olds }) {
160 const { accountId } = yield* yield* CloudflareEnvironment;
161 const acct = output?.accountId ?? accountId;
162
163 // Owned path: refresh by the persisted entry id.
164 if (output?.entryId) {
165 const observed = yield* getEntry(acct, output.entryId);
166 if (observed) return toAttributes(observed, acct);
167 }
168
169 // Cold lookup: recover by exact name + email; matches are reported
170 // as `Unowned` so takeover is gated behind the adopt policy.
171 const name = output?.name ?? olds?.name;
172 const email = output?.email ?? olds?.email;
173 if (name !== undefined && email !== undefined) {
174 const observed = yield* findByIdentity(acct, name, email);
175 if (observed) return Unowned(toAttributes(observed, acct));
176 }
177 return undefined;
178 }),
179
180 reconcile: Effect.fn(function* ({ news, output }) {
181 const { accountId } = yield* yield* CloudflareEnvironment;
182
183 // 1. Observe — id hint first, then identity scan.
184 let observed = output?.entryId
185 ? yield* getEntry(accountId, output.entryId)
186 : undefined;
187 if (!observed) {
188 observed = yield* findByIdentity(accountId, news.name, news.email);
189 }
190
191 // 2. Ensure — create when missing.
192 if (!observed) {
193 const created = yield* emailSecurity.createSettingImpersonationRegistry(
194 {
195 accountId,
196 name: news.name,
197 email: news.email,
198 isEmailRegex: news.isEmailRegex ?? false,
199 comments: news.comments,
200 },
201 );
202 return toAttributes(created, accountId);
203 }
204
205 // 3. Sync — patch only on a delta.
206 const dirty =
207 (observed.name ?? "") !== news.name ||
208 (observed.email ?? "") !== news.email ||
209 (observed.isEmailRegex ?? false) !== (news.isEmailRegex ?? false) ||
210 (news.comments !== undefined &&
211 (observed.comments ?? "") !== news.comments);
212 if (!dirty) {
213 return toAttributes(observed, accountId);
214 }
215
216 const patched = yield* emailSecurity.patchSettingImpersonationRegistry({
217 accountId,
218 impersonationRegistryId: observed.id ?? "",
219 name: news.name,
220 email: news.email,
221 isEmailRegex: news.isEmailRegex ?? false,
222 comments: news.comments,
223 });
224 return toAttributes(patched, accountId);
225 }),
226
227 delete: Effect.fn(function* ({ output }) {
228 yield* emailSecurity
229 .deleteSettingImpersonationRegistry({
230 accountId: output.accountId,
231 impersonationRegistryId: output.entryId,
232 })
233 .pipe(
234 Effect.catchTag(
235 "ImpersonationRegistryEntryNotFound",
236 () => Effect.void,
237 ),
238 );
239 }),
240 });
241
242type ObservedEntry = emailSecurity.GetSettingImpersonationRegistryResponse;
243
244/**
245 * Read an impersonation registry entry by id, mapping "gone"
246 * (`ImpersonationRegistryEntryNotFound`, HTTP 404) to `undefined`.
247 */
248const getEntry = (accountId: string, impersonationRegistryId: string) =>
249 emailSecurity
250 .getSettingImpersonationRegistry({ accountId, impersonationRegistryId })
251 .pipe(
252 Effect.map((entry): ObservedEntry | undefined => entry),
253 Effect.catchTag("ImpersonationRegistryEntryNotFound", () =>
254 Effect.succeed(undefined),
255 ),
256 );
257
258/**
259 * Find an entry by exact display name + email. The `search` query is a
260 * server-side hint; the exact match is re-checked client-side. Picks the
261 * oldest match for determinism.
262 */
263const findByIdentity = (accountId: string, name: string, email: string) =>
264 emailSecurity.listSettingImpersonationRegistries
265 .items({ accountId, search: email })
266 .pipe(
267 Stream.runCollect,
268 Effect.map((chunk) =>
269 Array.from(chunk)
270 .filter((entry) => entry.name === name && entry.email === email)
271 .sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""))
272 .at(0),
273 ),
274 );
275
276type ListedEntry =
277 emailSecurity.ListSettingImpersonationRegistriesResponse["result"][number];
278
279const toAttributes = (
280 entry:
281 | ObservedEntry
282 | emailSecurity.CreateSettingImpersonationRegistryResponse
283 | emailSecurity.PatchSettingImpersonationRegistryResponse
284 | ListedEntry,
285 accountId: string,
286): ImpersonationRegistryEntryAttributes => ({
287 entryId: entry.id ?? "",
288 accountId,
289 name: entry.name ?? "",
290 email: entry.email ?? "",
291 isEmailRegex: entry.isEmailRegex ?? false,
292 comments: entry.comments ?? undefined,
293 provenance: entry.provenance ?? undefined,
294 createdAt: entry.createdAt ?? "",
295 modifiedAt: entry.modifiedAt ?? undefined,
296});