1import * as emailSending from "@distilled.cloud/cloudflare/email-sending";
2import * as Effect from "effect/Effect";
3import * as Option from "effect/Option";
4import * as Predicate from "effect/Predicate";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7
8import { Unowned } from "../../AdoptPolicy.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import { CloudflareEnvironment } from "../CloudflareEnvironment.ts";
12import type { Providers } from "../Providers.ts";
13import { listAllZones } from "../Zone/lookup.ts";
14
15const SendingSubdomainTypeId = "Cloudflare.Email.SendingSubdomain" as const;
16type SendingSubdomainTypeId = typeof SendingSubdomainTypeId;
17
18export interface SendingSubdomainProps {
19 /**
20 * Zone the sending subdomain is registered on. The subdomain name must
21 * be within this zone.
22 *
23 * Stable — moving the subdomain to another zone triggers a replacement.
24 */
25 zoneId: string;
26 /**
27 * The fully-qualified subdomain to send email from (e.g.
28 * `mail.example.com`). Must be within the zone.
29 *
30 * Stable — the Cloudflare API has no update operation for sending
31 * subdomains, so a rename is a delete + create. Declared as plain
32 * `string` (not `string`) so it is statically knowable in `diff`.
33 */
34 name: string;
35}
36
37export interface SendingSubdomainAttributes {
38 /** Cloudflare-assigned identifier of the sending subdomain. */
39 subdomainId: string;
40 /** Zone the sending subdomain is registered on. */
41 zoneId: string;
42 /** The subdomain domain name. */
43 name: string;
44 /**
45 * Whether Email Sending is enabled on this subdomain. Flips to `true`
46 * once the auto-provisioned DNS records (DKIM/SPF/return-path) validate
47 * — usually immediate for zones on Cloudflare DNS.
48 */
49 enabled: boolean;
50 /** The DKIM selector used for email signing. */
51 dkimSelector: string | undefined;
52 /** The return-path domain used for bounce handling. */
53 returnPathDomain: string | undefined;
54 /** ISO8601 creation timestamp. */
55 created: string | undefined;
56 /** ISO8601 last-modified timestamp. */
57 modified: string | undefined;
58}
59
60export type SendingSubdomain = Resource<
61 SendingSubdomainTypeId,
62 SendingSubdomainProps,
63 SendingSubdomainAttributes,
64 never,
65 Providers
66>;
67
68/**
69 * Registers a Cloudflare Email Sending subdomain on a zone, enabling the
70 * account to send transactional email from addresses on that subdomain.
71 *
72 * Creating the subdomain provisions DKIM, SPF, and return-path
73 * configuration; for zones on Cloudflare DNS the required DNS records are
74 * created automatically and `enabled` flips to `true` once they validate
75 * (usually immediately).
76 *
77 * The resource is existence-only: the API offers create, get, list, and
78 * delete but no update, so changing `name` or `zoneId` triggers a
79 * replacement.
80 *
81 * Safety: sending subdomains carry no ownership markers. When there is no
82 * prior state, `read` scans the zone for an existing subdomain with the
83 * same name and reports it as `Unowned`, so the engine refuses to take it
84 * over unless `--adopt` (or `adopt(true)`) is set.
85 * ### Registering a sending subdomain
86 * **Example:** Send mail from `mail.example.com`
87 * ```typescript
88 * const sending = yield* Cloudflare.Email.SendingSubdomain("Mail", {
89 * zoneId: zone.zoneId,
90 * name: "mail.example.com",
91 * });
92 * // sending.enabled — true once DNS records validated
93 * // sending.dkimSelector / sending.returnPathDomain — provisioned config
94 * ```
95 *
96 * ### Externally-hosted zones
97 * **Example:** Look up the DNS records to create manually
98 * ```typescript
99 * import * as emailSending from "@distilled.cloud/cloudflare/email-sending";
100 *
101 * // For zones not on Cloudflare DNS, fetch the expected records and add
102 * // them at your DNS host; `enabled` flips to true once they validate.
103 * const records = yield* emailSending.getSubdomainDns.items({
104 * zoneId: sending.zoneId,
105 * subdomainId: sending.subdomainId,
106 * }).pipe(Stream.runCollect);
107 * ```
108 *
109 * @see https://developers.cloudflare.com/email-sending/
110 *
111 * @resource
112 * @product Email
113 * @category Email
114 */
115export const SendingSubdomain = Resource<SendingSubdomain>(
116 SendingSubdomainTypeId,
117 { aliases: ["Cloudflare.EmailSendingSubdomain"] },
118);
119
120/**
121 * Returns true if the given value is an SendingSubdomain resource.
122 */
123export const isSendingSubdomain = (value: unknown): value is SendingSubdomain =>
124 Predicate.hasProperty(value, "Type") && value.Type === SendingSubdomainTypeId;
125
126export const SendingSubdomainProvider = () =>
127 Provider.succeed(SendingSubdomain, {
128 // No update API exists — every attribute is stable across updates.
129 stables: [
130 "subdomainId",
131 "zoneId",
132 "name",
133 "dkimSelector",
134 "returnPathDomain",
135 "created",
136 ],
137
138 list: Effect.fn(function* () {
139 const { accountId } = yield* yield* CloudflareEnvironment;
140 // Sending subdomains are zone-scoped (`/zones/{id}/email/sending/
141 // subdomains`) with no account-wide list — enumerate every zone and
142 // list its subdomains, then flatten.
143 const zones = yield* listAllZones(accountId);
144 const rows = yield* Effect.forEach(
145 zones,
146 (zone) =>
147 emailSending.listSubdomains.pages({ zoneId: zone.id }).pipe(
148 Stream.runCollect,
149 Effect.map((chunk) =>
150 Array.from(chunk).flatMap((page) =>
151 (page.result ?? []).map((subdomain) =>
152 toAttributes(subdomain, zone.id),
153 ),
154 ),
155 ),
156 // Email Sending may be unavailable / plan-gated on a zone —
157 // skip those zones rather than fail the whole enumeration.
158 Effect.catchTag("Forbidden", () => Effect.succeed([])),
159 ),
160 { concurrency: 10 },
161 );
162 return rows.flat();
163 }),
164
165 diff: Effect.fn(function* ({ olds = {}, news }) {
166 const o = olds as SendingSubdomainProps;
167 const n = news as SendingSubdomainProps;
168 // The API has no update operation — any prop change is a replace.
169 if (o.name !== undefined && o.name !== n.name) {
170 return { action: "replace" } as const;
171 }
172 // zoneId is Input<string>; compare only once both are concrete.
173 if (
174 typeof o.zoneId === "string" &&
175 typeof n.zoneId === "string" &&
176 o.zoneId !== n.zoneId
177 ) {
178 return { action: "replace" } as const;
179 }
180 return undefined;
181 }),
182
183 read: Effect.fn(function* ({ output, olds }) {
184 const zoneId =
185 output?.zoneId ??
186 (typeof olds?.zoneId === "string" ? olds.zoneId : undefined);
187 if (!zoneId) return undefined;
188
189 // Owned path: refresh by our persisted subdomain id.
190 if (output?.subdomainId) {
191 const observed = yield* getSubdomain(zoneId, output.subdomainId);
192 if (observed) return toAttributes(observed, zoneId);
193 }
194
195 // Adoption path: a subdomain with this name may already exist on the
196 // zone. Sending subdomains carry no ownership markers, so we cannot
197 // prove we created it — brand it `Unowned` so the engine refuses to
198 // take over unless `adopt` is set.
199 const name = output?.name ?? olds?.name;
200 if (name) {
201 const observed = yield* findByName(zoneId, name);
202 if (observed) return Unowned(toAttributes(observed, zoneId));
203 }
204 return undefined;
205 }),
206
207 reconcile: Effect.fn(function* ({ news, output }) {
208 // Inputs have been resolved to concrete strings by Plan.
209 const zoneId = news.zoneId as string;
210
211 // 1. Observe — the subdomain id cached on `output` is a hint, not a
212 // guarantee: a missing subdomain falls through to the name scan
213 // and then to create.
214 let observed = output?.subdomainId
215 ? yield* getSubdomain(zoneId, output.subdomainId)
216 : undefined;
217
218 // 2. Fall back to scanning the zone for a name match. Ownership has
219 // already been verified upstream — `read` reports existing
220 // subdomains as `Unowned` and the engine gates takeover behind
221 // the adopt policy before reconcile ever runs.
222 if (!observed) {
223 observed = yield* findByName(zoneId, news.name);
224 }
225
226 // 3. Ensure — create when missing. A concurrent create surfaces as
227 // `SendingSubdomainAlreadyExists` (Cloudflare code 2040):
228 // converge by re-scanning for the subdomain that won the race.
229 if (!observed) {
230 observed = yield* emailSending
231 .createSubdomain({ zoneId, name: news.name })
232 .pipe(
233 Effect.catchTag("SendingSubdomainAlreadyExists", (error) =>
234 findByName(zoneId, news.name).pipe(
235 Effect.flatMap((existing) =>
236 existing ? Effect.succeed(existing) : Effect.fail(error),
237 ),
238 ),
239 ),
240 );
241 }
242
243 // 4. Sync — nothing is mutable; the only convergence left is DNS
244 // validation. Cloudflare auto-creates the records on CF-hosted
245 // zones but validation is eventually consistent: poll briefly
246 // for `enabled` to flip, and return the observed state either
247 // way (an externally-hosted zone stays disabled until the user
248 // adds the records — that is not a deploy failure).
249 const ensured = observed;
250 const final = ensured.enabled
251 ? ensured
252 : yield* getSubdomain(zoneId, ensured.tag).pipe(
253 Effect.map((latest) => latest ?? ensured),
254 Effect.repeat({
255 schedule: Schedule.spaced("5 seconds"),
256 until: (latest) => latest.enabled,
257 times: 12,
258 }),
259 );
260
261 // 5. Return.
262 return toAttributes(final, zoneId);
263 }),
264
265 delete: Effect.fn(function* ({ output }) {
266 // Idempotent — an already-gone subdomain answers with Cloudflare
267 // code 2033 (`SendingSubdomainNotFound`), which is success here.
268 yield* emailSending
269 .deleteSubdomain({
270 zoneId: output.zoneId,
271 subdomainId: output.subdomainId,
272 })
273 .pipe(Effect.catchTag("SendingSubdomainNotFound", () => Effect.void));
274 }),
275 });
276
277// ---------------------------------------------------------------------------
278// API helpers
279// ---------------------------------------------------------------------------
280
281type ObservedSubdomain = emailSending.GetSubdomainResponse;
282
283/**
284 * Read a sending subdomain by id, mapping "gone" (`SendingSubdomainNotFound`,
285 * Cloudflare error code 2033) to `undefined`.
286 */
287const getSubdomain = (zoneId: string, subdomainId: string) =>
288 emailSending.getSubdomain({ zoneId, subdomainId }).pipe(
289 Effect.map((subdomain): ObservedSubdomain | undefined => subdomain),
290 Effect.catchTag("SendingSubdomainNotFound", () =>
291 Effect.succeed(undefined),
292 ),
293 );
294
295/**
296 * Find a sending subdomain by exact name within the zone. The name is the
297 * subdomain's identity — Cloudflare rejects duplicates with code 2040 — so
298 * at most one can match.
299 */
300const findByName = (zoneId: string, name: string) =>
301 emailSending.listSubdomains.items({ zoneId }).pipe(
302 Stream.filter((subdomain) => subdomain.name === name),
303 Stream.runHead,
304 Effect.map(Option.getOrUndefined),
305 );
306
307const toAttributes = (
308 subdomain: ObservedSubdomain,
309 zoneId: string,
310): SendingSubdomainAttributes => ({
311 subdomainId: subdomain.tag,
312 zoneId,
313 name: subdomain.name,
314 enabled: subdomain.enabled,
315 dkimSelector: subdomain.dkimSelector ?? undefined,
316 returnPathDomain: subdomain.returnPathDomain ?? undefined,
317 created: subdomain.created ?? undefined,
318 modified: subdomain.modified ?? undefined,
319});