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";
11import type { PatternType } from "./AllowPolicy.ts";
12
13const EmailSecurityBlockSenderTypeId = "Cloudflare.Email.BlockSender" as const;
14type EmailSecurityBlockSenderTypeId = typeof EmailSecurityBlockSenderTypeId;
15
16export interface BlockSenderProps {
17 /**
18 * The email address, domain, IP, or regular expression to block.
19 * The pattern is the entry's identity for cold-state recovery — a
20 * pre-existing entry with the same pattern is reported as unowned and
21 * only taken over when adoption is enabled.
22 */
23 pattern: string;
24 /**
25 * Type of pattern matching.
26 */
27 patternType: PatternType;
28 /**
29 * Whether `pattern` is a regular expression.
30 * @default false
31 */
32 isRegex?: boolean;
33 /**
34 * Free-form notes about the blocked sender.
35 */
36 comments?: string;
37}
38
39export interface BlockSenderAttributes {
40 /** Cloudflare-assigned blocked sender pattern identifier. */
41 blockSenderId: string;
42 /** The account the entry belongs to. */
43 accountId: string;
44 /** The blocked pattern. */
45 pattern: string;
46 /** Type of pattern matching. */
47 patternType: PatternType;
48 /** Whether the pattern is a regular expression. */
49 isRegex: boolean;
50 /** Free-form notes about the entry, if set. */
51 comments: string | undefined;
52 /** ISO8601 creation timestamp. */
53 createdAt: string;
54 /** ISO8601 last-modified timestamp, if the entry has been modified. */
55 modifiedAt: string | undefined;
56}
57
58export type BlockSender = Resource<
59 EmailSecurityBlockSenderTypeId,
60 BlockSenderProps,
61 BlockSenderAttributes,
62 never,
63 Providers
64>;
65
66/**
67 * A Cloudflare Email Security (Area 1) blocked sender — messages matching
68 * the pattern are blocked before delivery.
69 *
70 * All fields are mutable in place. Requires the Email Security enterprise
71 * add-on; accounts without the entitlement receive the typed
72 * `EmailSecurityNotEntitled` error.
73 * ### Blocking Senders
74 * **Example:** Block a single email address
75 * ```typescript
76 * yield* Cloudflare.Email.BlockSender("KnownPhisher", {
77 * pattern: "phisher@malicious.example.com",
78 * patternType: "EMAIL",
79 * comments: "reported in incident 1234",
80 * });
81 * ```
82 *
83 * **Example:** Block a whole sending domain
84 * ```typescript
85 * yield* Cloudflare.Email.BlockSender("SpamDomain", {
86 * pattern: "spam-source.example.net",
87 * patternType: "DOMAIN",
88 * });
89 * ```
90 *
91 * **Example:** Block by regular expression
92 * ```typescript
93 * yield* Cloudflare.Email.BlockSender("LookalikeSenders", {
94 * pattern: ".*@examp1e\\.com$",
95 * patternType: "EMAIL",
96 * isRegex: true,
97 * });
98 * ```
99 *
100 * @see https://developers.cloudflare.com/cloudflare-one/email-security/
101 *
102 * @resource
103 * @product Email Security
104 * @category Email
105 */
106export const BlockSender = Resource<BlockSender>(
107 EmailSecurityBlockSenderTypeId,
108 { aliases: ["Cloudflare.EmailSecurity.BlockSender"] },
109);
110
111/**
112 * Returns true if the given value is an BlockSender resource.
113 */
114export const isBlockSender = (value: unknown): value is BlockSender =>
115 Predicate.hasProperty(value, "Type") &&
116 value.Type === EmailSecurityBlockSenderTypeId;
117
118export const BlockSenderProvider = () =>
119 Provider.succeed(BlockSender, {
120 stables: ["blockSenderId", "accountId", "createdAt"],
121
122 read: Effect.fn(function* ({ output, olds }) {
123 const { accountId } = yield* yield* CloudflareEnvironment;
124 const acct = output?.accountId ?? accountId;
125
126 // Owned path: refresh by the persisted entry id.
127 if (output?.blockSenderId) {
128 const observed = yield* getBlockSender(acct, output.blockSenderId);
129 if (observed) return toAttributes(observed, acct);
130 }
131
132 // Cold lookup: recover by pattern; matches are reported as `Unowned`
133 // so takeover is gated behind the adopt policy.
134 const pattern = output?.pattern ?? olds?.pattern;
135 if (pattern !== undefined) {
136 const observed = yield* findByPattern(acct, pattern);
137 if (observed) return Unowned(toAttributes(observed, acct));
138 }
139 return undefined;
140 }),
141
142 reconcile: Effect.fn(function* ({ news, output }) {
143 const { accountId } = yield* yield* CloudflareEnvironment;
144
145 // 1. Observe — id hint first, then pattern scan.
146 let observed = output?.blockSenderId
147 ? yield* getBlockSender(accountId, output.blockSenderId)
148 : undefined;
149 if (!observed) {
150 observed = yield* findByPattern(accountId, news.pattern);
151 }
152
153 // 2. Ensure — create when missing.
154 if (!observed) {
155 const created = yield* emailSecurity.createSettingBlockSender({
156 accountId,
157 pattern: news.pattern,
158 patternType: news.patternType,
159 isRegex: news.isRegex ?? false,
160 comments: news.comments,
161 });
162 return toAttributes(created, accountId);
163 }
164
165 // 3. Sync — patch only on a delta.
166 const dirty =
167 (observed.pattern ?? "") !== news.pattern ||
168 (observed.patternType ?? "") !== news.patternType ||
169 (observed.isRegex ?? false) !== (news.isRegex ?? false) ||
170 (news.comments !== undefined &&
171 (observed.comments ?? "") !== news.comments);
172 if (!dirty) {
173 return toAttributes(observed, accountId);
174 }
175
176 const patched = yield* emailSecurity.patchSettingBlockSender({
177 accountId,
178 patternId: observed.id ?? "",
179 pattern: news.pattern,
180 patternType: news.patternType,
181 isRegex: news.isRegex ?? false,
182 comments: news.comments,
183 });
184 return toAttributes(patched, accountId);
185 }),
186
187 delete: Effect.fn(function* ({ output }) {
188 yield* emailSecurity
189 .deleteSettingBlockSender({
190 accountId: output.accountId,
191 patternId: output.blockSenderId,
192 })
193 .pipe(Effect.catchTag("BlockSenderNotFound", () => Effect.void));
194 }),
195
196 // Account collection: enumerate every blocked sender in the ambient
197 // account, exhaustively paginating. Each list item already carries the
198 // full entry shape, so it hydrates directly into `read`'s Attributes with
199 // no per-item follow-up. Email Security is a paid add-on, so accounts
200 // without the entitlement (or without permission) have nothing to
201 // enumerate and yield an empty array.
202 list: Effect.fn(function* () {
203 const { accountId } = yield* yield* CloudflareEnvironment;
204 return yield* emailSecurity.listSettingBlockSenders
205 .pages({ accountId })
206 .pipe(
207 Stream.runCollect,
208 Effect.map((chunk) =>
209 Array.from(chunk).flatMap((page) =>
210 (page.result ?? []).map((entry) =>
211 toAttributes(entry, accountId),
212 ),
213 ),
214 ),
215 Effect.catchTag("EmailSecurityNotEntitled", () => Effect.succeed([])),
216 Effect.catchTag("Forbidden", () => Effect.succeed([])),
217 );
218 }),
219 });
220
221type ObservedBlockSender = emailSecurity.GetSettingBlockSenderResponse;
222
223/**
224 * Read a blocked sender by id, mapping "gone" (`BlockSenderNotFound`,
225 * HTTP 404) to `undefined`.
226 */
227const getBlockSender = (accountId: string, patternId: string) =>
228 emailSecurity.getSettingBlockSender({ accountId, patternId }).pipe(
229 Effect.map((entry): ObservedBlockSender | undefined => entry),
230 Effect.catchTag("BlockSenderNotFound", () => Effect.succeed(undefined)),
231 );
232
233/**
234 * Find a blocked sender by exact pattern. The `pattern` query filter is a
235 * server-side hint; the exact match is re-checked client-side. Picks the
236 * oldest match for determinism.
237 */
238const findByPattern = (accountId: string, pattern: string) =>
239 emailSecurity.listSettingBlockSenders.items({ accountId, pattern }).pipe(
240 Stream.runCollect,
241 Effect.map((chunk) =>
242 Array.from(chunk)
243 .filter((entry) => entry.pattern === pattern)
244 .sort((a, b) => (a.createdAt ?? "").localeCompare(b.createdAt ?? ""))
245 .at(0),
246 ),
247 );
248
249const toAttributes = (
250 entry:
251 | ObservedBlockSender
252 | emailSecurity.CreateSettingBlockSenderResponse
253 | emailSecurity.PatchSettingBlockSenderResponse
254 | emailSecurity.ListSettingBlockSendersResponse["result"][number],
255 accountId: string,
256): BlockSenderAttributes => ({
257 blockSenderId: entry.id ?? "",
258 accountId,
259 pattern: entry.pattern ?? "",
260 patternType: (entry.patternType ?? "EMAIL") as PatternType,
261 isRegex: entry.isRegex ?? false,
262 comments: entry.comments ?? undefined,
263 createdAt: entry.createdAt ?? "",
264 modifiedAt: entry.modifiedAt ?? undefined,
265});