Skip to content

Commit dc4615f

Browse files
feat(cloudflare): SendingSubdomain previewEnabled and email.sending queue subscriptions (#1914)
Co-authored-by: Sam Goodwin <sam@alchemy.run>
1 parent 73cf899 commit dc4615f

4 files changed

Lines changed: 163 additions & 8 deletions

File tree

‎packages/alchemy/src/Cloudflare/Email/SendingSubdomain.ts‎

Lines changed: 36 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,15 @@ export interface SendingSubdomainProps {
3131
* `string` (not `string`) so it is statically knowable in `diff`.
3232
*/
3333
name: string;
34+
/**
35+
* Whether Cloudflare keeps a preview of each sent message in the activity
36+
* log (retained for about seven days). Subdomains onboarded on or after
37+
* 2026-07-02 start with previews on. Omit to leave the setting as
38+
* Cloudflare has it.
39+
*
40+
* @see https://developers.cloudflare.com/email-service/observability/logs/#message-preview
41+
*/
42+
previewEnabled?: boolean;
3443
}
3544

3645
export interface SendingSubdomainAttributes {
@@ -50,6 +59,8 @@ export interface SendingSubdomainAttributes {
5059
dkimSelector: string | undefined;
5160
/** The return-path domain used for bounce handling. */
5261
returnPathDomain: string | undefined;
62+
/** Whether sent messages can be previewed in the activity log. */
63+
previewEnabled: boolean | undefined;
5364
/** ISO8601 creation timestamp. */
5465
created: string | undefined;
5566
/** ISO8601 last-modified timestamp. */
@@ -73,9 +84,8 @@ export type SendingSubdomain = Resource<
7384
* created automatically and `enabled` flips to `true` once they validate
7485
* (usually immediately).
7586
*
76-
* The resource is existence-only: the API offers create, get, list, and
77-
* delete but no update, so changing `name` or `zoneId` triggers a
78-
* replacement.
87+
* Changing `name` or `zoneId` triggers a replacement; `previewEnabled` is
88+
* updated in place.
7989
*
8090
* Safety: sending subdomains carry no ownership markers. When there is no
8191
* prior state, `read` scans the zone for an existing subdomain with the
@@ -92,6 +102,16 @@ export type SendingSubdomain = Resource<
92102
* // sending.dkimSelector / sending.returnPathDomain — provisioned config
93103
* ```
94104
*
105+
* ### Message previews
106+
* **Example:** Keep no copies of sent messages in the activity log
107+
* ```typescript
108+
* const sending = yield* Cloudflare.Email.SendingSubdomain("Mail", {
109+
* zoneId: zone.zoneId,
110+
* name: "mail.example.com",
111+
* previewEnabled: false,
112+
* });
113+
* ```
114+
*
95115
* ### Externally-hosted zones
96116
* **Example:** Look up the DNS records to create manually
97117
* ```typescript
@@ -123,7 +143,7 @@ export const isSendingSubdomain = (value: unknown): value is SendingSubdomain =>
123143

124144
export const SendingSubdomainProvider = () =>
125145
Provider.succeed(SendingSubdomain, {
126-
// No update API exists — every attribute is stable across updates.
146+
// Only `previewEnabled` changes in place.
127147
stables: ["subdomainId", "zoneId", "name", "dkimSelector", "returnPathDomain", "created"],
128148

129149
list: Effect.fn(function* () {
@@ -154,7 +174,7 @@ export const SendingSubdomainProvider = () =>
154174
diff: Effect.fn(function* ({ olds = {}, news }) {
155175
const o = olds as SendingSubdomainProps;
156176
const n = news as SendingSubdomainProps;
157-
// The API has no update operation — any prop change is a replace.
177+
// `name` and `zoneId` are identity; `previewEnabled` updates in place.
158178
if (o.name !== undefined && o.name !== n.name) {
159179
return { action: "replace" } as const;
160180
}
@@ -223,13 +243,20 @@ export const SendingSubdomainProvider = () =>
223243
);
224244
}
225245

226-
// 4. Sync — nothing is mutable; the only convergence left is DNS
227-
// validation. Cloudflare auto-creates the records on CF-hosted
246+
// 4. Sync — converge `previewEnabled` when it is declared, then wait
247+
// for DNS validation. Cloudflare auto-creates the records on CF-hosted
228248
// zones but validation is eventually consistent: poll briefly
229249
// for `enabled` to flip, and return the observed state either
230250
// way (an externally-hosted zone stays disabled until the user
231251
// adds the records — that is not a deploy failure).
232-
const ensured = observed;
252+
const ensured =
253+
news.previewEnabled !== undefined && observed.previewEnabled !== news.previewEnabled
254+
? yield* emailSending.editSubdomain({
255+
zoneId,
256+
subdomainId: observed.tag,
257+
previewEnabled: news.previewEnabled,
258+
})
259+
: observed;
233260
const final = ensured.enabled
234261
? ensured
235262
: yield* getSubdomain(zoneId, ensured.tag).pipe(
@@ -295,6 +322,7 @@ const toAttributes = (
295322
enabled: subdomain.enabled,
296323
dkimSelector: subdomain.dkimSelector ?? undefined,
297324
returnPathDomain: subdomain.returnPathDomain ?? undefined,
325+
previewEnabled: subdomain.previewEnabled ?? undefined,
298326
created: subdomain.created ?? undefined,
299327
modified: subdomain.modified ?? undefined,
300328
});

‎packages/alchemy/src/Cloudflare/Queues/Subscription.ts‎

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,14 @@ export type SubscriptionSource =
6969
type: "workflows.workflow";
7070
/** Name of the workflow to subscribe to. */
7171
workflowName: string;
72+
}
73+
| {
74+
/** Email Sending lifecycle events for one sending domain. */
75+
type: "email.sending";
76+
/** Zone the sending domain belongs to. */
77+
zoneId: string;
78+
/** The zone apex or a verified sending subdomain, e.g. `mail.example.com`. */
79+
domain: string;
7280
};
7381

7482
export type SubscriptionProps = {
@@ -369,6 +377,15 @@ const validateSourceAccount = (accountId: string, sourceAccountId?: string) =>
369377
* });
370378
* ```
371379
*
380+
* **Example:** Bounces and complaints from an Email Sending domain
381+
* ```typescript
382+
* const subscription = yield* Cloudflare.Queues.Subscription("MailEvents", {
383+
* source: { type: "email.sending", zoneId: zone.zoneId, domain: "mail.example.com" },
384+
* events: ["message.bounced", "message.complained"],
385+
* queueId: queue.queueId,
386+
* });
387+
* ```
388+
*
372389
* ### Resource and Reference Sources
373390
* **Example:** Account-wide KV events from a namespace
374391
* ```typescript
@@ -670,6 +687,8 @@ type WireSource = {
670687
modelName?: string | null;
671688
workerName?: string | null;
672689
workflowName?: string | null;
690+
zoneId?: string | null;
691+
domain?: string | null;
673692
};
674693

675694
const toSource = (wire: unknown): SubscriptionSource => {
@@ -687,6 +706,12 @@ const toSource = (wire: unknown): SubscriptionSource => {
687706
type: "workflows.workflow",
688707
workflowName: source.workflowName ?? "",
689708
};
709+
case "email.sending":
710+
return {
711+
type: "email.sending",
712+
zoneId: source.zoneId ?? "",
713+
domain: source.domain ?? "",
714+
};
690715
case "images":
691716
case "kv":
692717
case "r2":
@@ -709,6 +734,10 @@ const sameSource = (a: SubscriptionSource, b: SubscriptionSource): boolean => {
709734
return a.workerName === (b as { workerName?: string }).workerName;
710735
case "workflows.workflow":
711736
return a.workflowName === (b as { workflowName?: string }).workflowName;
737+
case "email.sending": {
738+
const other = b as { zoneId?: string; domain?: string };
739+
return a.zoneId === other.zoneId && a.domain === other.domain;
740+
}
712741
default:
713742
return true;
714743
}

‎packages/alchemy/test/Cloudflare/Email/SendingSubdomain.test.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ const NAME_REPLACE_A = `alchemy-sendsub-replace-a.${zoneName}`;
2828
const NAME_REPLACE_B = `alchemy-sendsub-replace-b.${zoneName}`;
2929
const NAME_ADOPT = `alchemy-sendsub-adopt.${zoneName}`;
3030
const NAME_LIST = `alchemy-sendsub-list.${zoneName}`;
31+
const NAME_PREVIEW = `alchemy-sendsub-preview.${zoneName}`;
3132

3233
const resolveZoneId = Effect.gen(function* () {
3334
const { accountId } = yield* yield* CloudflareEnvironment;
@@ -134,6 +135,42 @@ test.provider.skipIf(!emailRoutingScoped)(
134135
},
135136
);
136137

138+
test.provider.skipIf(!emailRoutingScoped)(
139+
"previewEnabled is set on create and updated in place",
140+
(stack) =>
141+
Effect.gen(function* () {
142+
const zoneId = yield* resolveZoneId;
143+
144+
yield* stack.destroy();
145+
yield* purgeSubdomain(zoneId, NAME_PREVIEW);
146+
147+
const deploy = (previewEnabled: boolean) =>
148+
stack.deploy(
149+
Effect.gen(function* () {
150+
return yield* Cloudflare.Email.SendingSubdomain("PreviewSending", {
151+
zoneId,
152+
name: NAME_PREVIEW,
153+
previewEnabled,
154+
});
155+
}),
156+
);
157+
158+
const off = yield* deploy(false);
159+
expect(off.previewEnabled).toEqual(false);
160+
expect((yield* getSubdomain(zoneId, off.subdomainId)).previewEnabled).toEqual(false);
161+
162+
const on = yield* deploy(true);
163+
expect(on.subdomainId).toEqual(off.subdomainId);
164+
expect(on.previewEnabled).toEqual(true);
165+
expect((yield* getSubdomain(zoneId, on.subdomainId)).previewEnabled).toEqual(true);
166+
167+
yield* stack.destroy();
168+
}).pipe(logLevel),
169+
{
170+
tags: ["provider:cloudflare", "provider:cloudflare:email", "provider:cloudflare:zone", "live"],
171+
},
172+
);
173+
137174
test.provider.skipIf(!emailRoutingScoped)(
138175
"changing the name triggers replacement",
139176
(stack) =>

‎packages/alchemy/test/Cloudflare/Queue/Subscription.test.ts‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,10 @@ import { MinimumLogLevel } from "effect/References";
55
import * as Schedule from "effect/Schedule";
66
import * as Cloudflare from "@/Cloudflare";
77
import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
8+
import { findZoneByName } from "@/Cloudflare/Zone/lookup";
89
import * as Provider from "@/Provider";
910
import * as Test from "@/Test/Alchemy";
11+
import { emailRoutingScoped } from "../Email/scope.ts";
1012
const { test } = Test.make({ providers: Cloudflare.providers() });
1113

1214
const logLevel = Effect.provideService(MinimumLogLevel, process.env.DEBUG ? "Debug" : "Info");
@@ -255,5 +257,64 @@ describe.sequential(
255257
yield* stack.destroy();
256258
}).pipe(logLevel),
257259
);
260+
261+
// Creates a SendingSubdomain, which needs the Email Routing scope the
262+
// rest of this file doesn't (see test/Cloudflare/Email/scope.ts).
263+
test.provider.skipIf(!emailRoutingScoped)(
264+
"create an email.sending subscription that keeps its zone and domain",
265+
(stack) =>
266+
Effect.gen(function* () {
267+
const { accountId } = yield* yield* CloudflareEnvironment;
268+
const zoneName = process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us";
269+
const zone = yield* findZoneByName({ accountId, name: zoneName });
270+
if (!zone) {
271+
return yield* Effect.die(new Error(`zone "${zoneName}" not found`));
272+
}
273+
const domain = `alchemy-sub-mail.${zoneName}`;
274+
275+
yield* stack.destroy();
276+
277+
const deployed = yield* stack.deploy(
278+
Effect.gen(function* () {
279+
const queue = yield* Cloudflare.Queues.Queue("MailQueue", {
280+
name: "alchemy-test-sub-mail-queue",
281+
});
282+
const sending = yield* Cloudflare.Email.SendingSubdomain("MailSending", {
283+
zoneId: zone.id,
284+
name: domain,
285+
});
286+
const subscription = yield* Cloudflare.Queues.Subscription("MailEvents", {
287+
source: {
288+
type: "email.sending",
289+
zoneId: sending.zoneId,
290+
domain: sending.name,
291+
},
292+
events: ["message.bounced", "message.complained"],
293+
queueId: queue.queueId,
294+
});
295+
return { queue, subscription };
296+
}),
297+
);
298+
299+
const sub = deployed.subscription;
300+
expect(sub.source).toEqual({
301+
type: "email.sending",
302+
zoneId: zone.id,
303+
domain,
304+
});
305+
306+
const live = yield* getSubscription(accountId, sub.subscriptionId);
307+
expect(live.source).toMatchObject({
308+
type: "email.sending",
309+
zoneId: zone.id,
310+
domain,
311+
});
312+
313+
yield* stack.destroy();
314+
315+
yield* expectGone(accountId, sub.subscriptionId);
316+
}).pipe(logLevel),
317+
{ tags: ["provider:cloudflare:email"] },
318+
);
258319
},
259320
);

0 commit comments

Comments
 (0)