1import * as sesv2 from "@distilled.cloud/aws/sesv2";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import * as Stream from "effect/Stream";
5import { Unowned } from "../../AdoptPolicy.ts";
6import { isResolved } from "../../Diff.ts";
7import { createPhysicalName } from "../../PhysicalName.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import {
11 createInternalTags,
12 createTagsList,
13 diffTags,
14 hasAlchemyTags,
15} from "../../Tags.ts";
16import { AWSEnvironment } from "../Environment.ts";
17import type { Providers } from "../Providers.ts";
18
19/**
20 * The lifecycle status of a multi-region endpoint: `CREATING`, `READY`,
21 * `FAILED`, or `DELETING`.
22 */
23export type MultiRegionEndpointStatus = sesv2.Status;
24
25export interface MultiRegionEndpointProps {
26 /**
27 * Name of the multi-region endpoint. If omitted, a deterministic physical
28 * name is generated from the app, stage, and logical ID. Changing the name
29 * replaces the endpoint.
30 */
31 endpointName?: string;
32 /**
33 * The secondary AWS regions the endpoint routes traffic across, e.g.
34 * `["eu-west-1"]`. The primary region is wherever the resource is created;
35 * sending traffic is split across it and every region listed here.
36 *
37 * There is no update API, so any change to this list replaces the endpoint.
38 */
39 regions: string[];
40 /**
41 * Tags to apply to the endpoint. Merged with internal Alchemy tags.
42 */
43 tags?: Record<string, string>;
44}
45
46export interface MultiRegionEndpoint extends Resource<
47 "AWS.SES.MultiRegionEndpoint",
48 MultiRegionEndpointProps,
49 {
50 /** Name of the multi-region endpoint. */
51 endpointName: string;
52 /** Opaque endpoint identifier assigned by SES. */
53 endpointId: string;
54 /**
55 * The endpoint's provisioning status at the time reconcile returned.
56 * Provisioning is asynchronous and starts as `CREATING`; the endpoint is
57 * not usable until it reaches `READY`. Reconcile does NOT wait for
58 * `READY` — poll `getMultiRegionEndpoint` downstream if you need to block
59 * on readiness.
60 */
61 status: MultiRegionEndpointStatus;
62 },
63 never,
64 Providers
65> {}
66
67/**
68 * An Amazon SES v2 multi-region endpoint (global endpoint) — a single sending
69 * endpoint that splits email traffic across a primary region (where the
70 * endpoint is created) and one or more secondary regions, improving
71 * resilience and deliverability.
72 *
73 * :::note
74 * Provisioning is asynchronous and can take a while: creation returns
75 * immediately with status `CREATING`, and the endpoint only becomes usable
76 * once it reaches `READY`. This resource does **not** wait for `READY` — the
77 * `status` attribute reflects the value observed when reconcile returned.
78 * Poll `getMultiRegionEndpoint` yourself if you need to block on readiness.
79 * :::
80 *
81 * There is no update API, so any change to the name or routes replaces the
82 * endpoint.
83 * ### Creating Endpoints
84 * **Example:** Two-Region Endpoint
85 * ```typescript
86 * import * as SES from "alchemy/AWS/SES";
87 *
88 * // The primary region is wherever the stack deploys; the route adds a
89 * // secondary region.
90 * const endpoint = yield* SES.MultiRegionEndpoint("Global", {
91 * regions: ["eu-west-1"],
92 * });
93 * ```
94 *
95 * **Example:** Three-Region Endpoint
96 * ```typescript
97 * // Traffic is split across the primary region plus every listed route.
98 * const endpoint = yield* SES.MultiRegionEndpoint("Global", {
99 * regions: ["eu-west-1", "ap-southeast-2"],
100 * });
101 * ```
102 *
103 * **Example:** Explicit Endpoint Name
104 * ```typescript
105 * const endpoint = yield* SES.MultiRegionEndpoint("Global", {
106 * endpointName: "acme-global",
107 * regions: ["eu-west-1"],
108 * });
109 * ```
110 *
111 * ### Waiting for READY
112 * **Example:** Poll Until the Endpoint Is Usable
113 * ```typescript
114 * import * as sesv2 from "@distilled.cloud/aws/sesv2";
115 * import * as Effect from "effect/Effect";
116 * import * as Schedule from "effect/Schedule";
117 *
118 * const endpoint = yield* SES.MultiRegionEndpoint("Global", {
119 * regions: ["eu-west-1"],
120 * });
121 *
122 * // Reconcile returns as soon as SES accepts the create, so status is
123 * // usually CREATING. Poll yourself when you need to block on readiness.
124 * const ready = yield* sesv2
125 * .getMultiRegionEndpoint({ EndpointName: yield* endpoint.endpointName })
126 * .pipe(
127 * Effect.repeat({
128 * schedule: Schedule.spaced("30 seconds"),
129 * until: (r) => r.Status === "READY",
130 * times: 40,
131 * }),
132 * );
133 * ```
134 *
135 * @resource
136 */
137export const MultiRegionEndpoint = Resource<MultiRegionEndpoint>(
138 "AWS.SES.MultiRegionEndpoint",
139);
140
141const toTagRecord = (
142 tags: ReadonlyArray<{ Key: string; Value: string }> | undefined,
143): Record<string, string> =>
144 Object.fromEntries((tags ?? []).map((tag) => [tag.Key, tag.Value]));
145
146// getMultiRegionEndpoint returns no ARN, so the ARN listTagsForResource needs
147// is derived from the endpoint NAME (not its id) — verified live.
148const endpointArnOf = (region: string, accountId: string, name: string) =>
149 `arn:aws:ses:${region}:${accountId}:multi-region-endpoint/${name}`;
150
151const sameRegions = (
152 a: ReadonlyArray<string> | undefined,
153 b: ReadonlyArray<string> | undefined,
154): boolean => {
155 const key = (regions: ReadonlyArray<string> | undefined) =>
156 JSON.stringify([...(regions ?? [])].sort());
157 return key(a) === key(b);
158};
159
160export const MultiRegionEndpointProvider = () =>
161 Provider.effect(
162 MultiRegionEndpoint,
163 Effect.gen(function* () {
164 const createName = Effect.fn(function* (
165 id: string,
166 props: Pick<MultiRegionEndpointProps, "endpointName">,
167 ) {
168 return (
169 props.endpointName ??
170 (yield* createPhysicalName({ id, maxLength: 64 }))
171 );
172 });
173
174 const getEndpoint = Effect.fn(function* (name: string) {
175 return yield* sesv2
176 .getMultiRegionEndpoint({ EndpointName: name })
177 .pipe(
178 Effect.catchTag("NotFoundException", () =>
179 Effect.succeed(undefined),
180 ),
181 );
182 });
183
184 // getMultiRegionEndpoint does not return tags, so ownership costs a
185 // second API call. Only `read` pays it — `list` deletes by name.
186 const getEndpointTags = Effect.fn(function* (name: string) {
187 const { accountId, region } = yield* AWSEnvironment.current;
188 return yield* sesv2
189 .listTagsForResource({
190 ResourceArn: endpointArnOf(region, accountId, name),
191 })
192 .pipe(
193 Effect.map((response) => toTagRecord(response.Tags)),
194 Effect.catchTag("NotFoundException", () =>
195 Effect.succeed({} as Record<string, string>),
196 ),
197 );
198 });
199
200 return MultiRegionEndpoint.Provider.of({
201 stables: ["endpointName", "endpointId"],
202
203 // Account/region-scoped: enumerate every endpoint so leaked test
204 // resources are cleaned by nuke.
205 list: Effect.fn(function* () {
206 const pages = yield* sesv2.listMultiRegionEndpoints
207 .pages({})
208 .pipe(Stream.runCollect);
209 return Array.from(pages)
210 .flatMap((page) => page.MultiRegionEndpoints ?? [])
211 .flatMap((entry) =>
212 entry.EndpointName && entry.EndpointId
213 ? [
214 {
215 endpointName: entry.EndpointName,
216 endpointId: entry.EndpointId,
217 status: entry.Status ?? "CREATING",
218 },
219 ]
220 : [],
221 );
222 }),
223
224 read: Effect.fn(function* ({ id, olds, output }) {
225 const name =
226 output?.endpointName ?? (yield* createName(id, olds ?? {}));
227 const found = yield* getEndpoint(name);
228 if (!found || !found.EndpointId) return undefined;
229 const attrs = {
230 endpointName: name,
231 endpointId: found.EndpointId,
232 status: found.Status ?? "CREATING",
233 };
234 // Endpoints are taggable and reconcile brands the ones it creates,
235 // so existence at our deterministic name is not proof of ownership.
236 const tags = yield* getEndpointTags(name);
237 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
238 }),
239
240 diff: Effect.fn(function* ({ id, news, olds }) {
241 if (!isResolved(news)) return undefined;
242 const oldName = yield* createName(id, olds ?? {});
243 const newName = yield* createName(id, news ?? {});
244 // No update API: a rename or any routing change replaces the
245 // endpoint.
246 if (
247 oldName !== newName ||
248 !sameRegions(olds?.regions, news.regions)
249 ) {
250 return { action: "replace" } as const;
251 }
252 }),
253
254 reconcile: Effect.fn(function* ({ id, news, output, session }) {
255 const name = output?.endpointName ?? (yield* createName(id, news));
256 const internalTags = yield* createInternalTags(id);
257 const desiredTags = { ...news.tags, ...internalTags };
258
259 // 1. OBSERVE — cloud state is authoritative.
260 let observed = yield* getEndpoint(name);
261
262 if (observed === undefined) {
263 // 2. ENSURE — create; AlreadyExists is a race, not a failure.
264 // Provisioning is asynchronous: creation returns CREATING and we
265 // deliberately do NOT wait for READY (that exceeds the polling
266 // budget; downstream consumers poll if they need readiness).
267 yield* sesv2
268 .createMultiRegionEndpoint({
269 EndpointName: name,
270 Details: {
271 RoutesDetails: news.regions.map((region) => ({
272 Region: region,
273 })),
274 },
275 Tags: createTagsList(desiredTags),
276 })
277 .pipe(
278 Effect.catchTag("AlreadyExistsException", () =>
279 Effect.succeed({}),
280 ),
281 );
282 // The endpoint is not always readable the instant create returns
283 // (and on the AlreadyExists race another writer may still be
284 // mid-create), so poll briefly rather than failing outright.
285 observed = yield* getEndpoint(name).pipe(
286 Effect.repeat({
287 schedule: Schedule.spaced("1 second"),
288 until: (endpoint) => endpoint !== undefined,
289 times: 8,
290 }),
291 );
292 }
293
294 if (observed === undefined || !observed.EndpointId) {
295 return yield* Effect.fail(
296 new Error(
297 `SES multi-region endpoint ${name} was not found after create`,
298 ),
299 );
300 }
301
302 // 3. SYNC TAGS — diff against OBSERVED cloud tags so an adopted
303 // endpoint gets branded and stops reading as Unowned.
304 const observedTags = yield* getEndpointTags(name);
305 const { upsert, removed } = diffTags(observedTags, desiredTags);
306 if (upsert.length > 0 || removed.length > 0) {
307 const { accountId, region } = yield* AWSEnvironment.current;
308 const endpointArn = endpointArnOf(region, accountId, name);
309 if (upsert.length > 0) {
310 yield* sesv2.tagResource({
311 ResourceArn: endpointArn,
312 Tags: upsert,
313 });
314 }
315 if (removed.length > 0) {
316 yield* sesv2.untagResource({
317 ResourceArn: endpointArn,
318 TagKeys: removed,
319 });
320 }
321 }
322
323 yield* session.note(observed.EndpointId);
324 return {
325 endpointName: name,
326 endpointId: observed.EndpointId,
327 status: observed.Status ?? "CREATING",
328 };
329 }),
330
331 delete: Effect.fn(function* ({ output }) {
332 // deleteMultiRegionEndpoint is idempotent for a missing endpoint.
333 //
334 // Provisioning is asynchronous and reconcile deliberately returns at
335 // CREATING, so a destroy that follows closely enough hits an endpoint
336 // still coming up: SES answers ConcurrentModificationException
337 // ("Unable to delete resource in PROVISIONING status"). That is
338 // eventual consistency, not a failure — retry on a bounded schedule
339 // until the endpoint settles and the delete takes.
340 yield* sesv2
341 .deleteMultiRegionEndpoint({ EndpointName: output.endpointName })
342 .pipe(
343 Effect.retry({
344 while: (e) => e._tag === "ConcurrentModificationException",
345 schedule: Schedule.max([
346 Schedule.spaced("5 seconds"),
347 Schedule.recurs(12),
348 ]),
349 }),
350 Effect.catchTag("NotFoundException", () => Effect.void),
351 );
352 }),
353 });
354 }),
355 );