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 pool's scaling mode. `STANDARD` pools use a fixed set of dedicated IPs
21 * you request and warm up yourself; `MANAGED` pools let SES automatically
22 * scale the dedicated IP capacity for you.
23 */
24export type DedicatedIpPoolScalingMode = sesv2.ScalingMode;
25
26export interface DedicatedIpPoolProps {
27 /**
28 * Name of the dedicated IP pool. May contain lowercase letters, numbers and
29 * dashes, up to 64 characters. If omitted, a deterministic lowercase
30 * physical name is generated from the app, stage, and logical ID. Changing
31 * the name replaces the pool.
32 */
33 poolName?: string;
34 /**
35 * The pool's scaling mode. Switching `STANDARD` → `MANAGED` is applied in
36 * place via `putDedicatedIpPoolScalingAttributes`; AWS does not support
37 * `MANAGED` → `STANDARD`, so that direction replaces the pool.
38 * @default "STANDARD"
39 */
40 scalingMode?: DedicatedIpPoolScalingMode;
41 /**
42 * Tags to apply to the pool. Merged with internal Alchemy tags.
43 */
44 tags?: Record<string, string>;
45}
46
47export interface DedicatedIpPool extends Resource<
48 "AWS.SES.DedicatedIpPool",
49 DedicatedIpPoolProps,
50 {
51 /** Name of the dedicated IP pool. */
52 poolName: string;
53 /** The pool's scaling mode. */
54 scalingMode: DedicatedIpPoolScalingMode;
55 },
56 never,
57 Providers
58> {}
59
60/**
61 * An Amazon SES v2 dedicated IP pool — a named group of dedicated IP addresses
62 * used to send email, so you can isolate the sending reputation of different
63 * kinds of mail (e.g. marketing vs. transactional).
64 *
65 * :::caution
66 * Creating a dedicated IP pool provisions dedicated IP capacity and **starts
67 * billing immediately** — `MANAGED` pools bill for managed dedicated IP usage
68 * as soon as they exist, and `STANDARD` pools bill per dedicated IP you add.
69 * Only create pools you intend to pay for.
70 * :::
71 *
72 * `STANDARD` → `MANAGED` is an in-place scaling change. `MANAGED` → `STANDARD`
73 * is not supported by AWS and replaces the pool.
74 * ### Creating Pools
75 * **Example:** Standard Pool
76 * ```typescript
77 * import * as SES from "alchemy/AWS/SES";
78 *
79 * const pool = yield* SES.DedicatedIpPool("Marketing", {
80 * scalingMode: "STANDARD",
81 * });
82 * ```
83 *
84 * **Example:** Managed Pool
85 * ```typescript
86 * const pool = yield* SES.DedicatedIpPool("Transactional", {
87 * scalingMode: "MANAGED",
88 * });
89 * ```
90 *
91 * **Example:** Explicit Pool Name
92 * ```typescript
93 * // Without poolName a deterministic lowercase name is derived from
94 * // app/stage/id. Pool names allow lowercase letters, numbers, and dashes.
95 * const pool = yield* SES.DedicatedIpPool("Marketing", {
96 * poolName: "acme-marketing",
97 * });
98 * ```
99 *
100 * ### Changing the Scaling Mode
101 * **Example:** Migrate a Standard Pool to Managed
102 * ```typescript
103 * // STANDARD -> MANAGED is applied in place — the pool keeps its name and
104 * // its dedicated IPs.
105 * const pool = yield* SES.DedicatedIpPool("Marketing", {
106 * scalingMode: "MANAGED", // was "STANDARD"
107 * });
108 *
109 * // MANAGED -> STANDARD has no AWS API, so it REPLACES the pool: a new pool
110 * // is created and the old one deleted, dropping its dedicated IPs.
111 * ```
112 *
113 * ### Isolating Reputation
114 * **Example:** Separate Marketing and Transactional Reputation
115 * ```typescript
116 * // Give each kind of mail its own pool so a marketing reputation hit
117 * // cannot take down password resets.
118 * const marketing = yield* SES.DedicatedIpPool("Marketing", {
119 * scalingMode: "STANDARD",
120 * });
121 * const transactional = yield* SES.DedicatedIpPool("Transactional", {
122 * scalingMode: "MANAGED",
123 * });
124 * ```
125 *
126 * @resource
127 */
128export const DedicatedIpPool = Resource<DedicatedIpPool>(
129 "AWS.SES.DedicatedIpPool",
130);
131
132const DEFAULT_SCALING_MODE = "STANDARD" as const;
133
134const toTagRecord = (
135 tags: ReadonlyArray<{ Key: string; Value: string }> | undefined,
136): Record<string, string> =>
137 Object.fromEntries((tags ?? []).map((tag) => [tag.Key, tag.Value]));
138
139// Dedicated IP pools are taggable but their API never returns an ARN — not
140// from getDedicatedIpPool, not from listDedicatedIpPools — so the ARN that
141// listTagsForResource requires has to be derived.
142const dedicatedIpPoolArnOf = (
143 region: string,
144 accountId: string,
145 name: string,
146) => `arn:aws:ses:${region}:${accountId}:dedicated-ip-pool/${name}`;
147
148export const DedicatedIpPoolProvider = () =>
149 Provider.effect(
150 DedicatedIpPool,
151 Effect.gen(function* () {
152 const createName = Effect.fn(function* (
153 id: string,
154 props: Pick<DedicatedIpPoolProps, "poolName">,
155 ) {
156 return (
157 props.poolName ??
158 (yield* createPhysicalName({ id, maxLength: 64, lowercase: true }))
159 );
160 });
161
162 const getPool = Effect.fn(function* (name: string) {
163 return yield* sesv2.getDedicatedIpPool({ PoolName: name }).pipe(
164 Effect.map((response) => response.DedicatedIpPool),
165 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
166 );
167 });
168
169 // getDedicatedIpPool does not return tags, so ownership costs a second
170 // API call. Only `read` pays it — `list` deletes by name and does not
171 // need to know who owns a pool.
172 const getPoolTags = Effect.fn(function* (name: string) {
173 const { accountId, region } = yield* AWSEnvironment.current;
174 return yield* sesv2
175 .listTagsForResource({
176 ResourceArn: dedicatedIpPoolArnOf(region, accountId, name),
177 })
178 .pipe(
179 Effect.map((response) => toTagRecord(response.Tags)),
180 // The pool went away between the two calls.
181 Effect.catchTag("NotFoundException", () =>
182 Effect.succeed({} as Record<string, string>),
183 ),
184 );
185 });
186
187 return DedicatedIpPool.Provider.of({
188 stables: ["poolName"],
189
190 // Account/region-scoped: enumerate every pool so leaked test resources
191 // are cleaned by nuke.
192 //
193 // listDedicatedIpPools returns names only, so the real ScalingMode
194 // needs a read per pool — `list` must produce the same Attributes
195 // shape as `read`, and reporting every pool as STANDARD would
196 // misdescribe a MANAGED one. Pools that vanish mid-walk drop out.
197 list: Effect.fn(function* () {
198 const pages = yield* sesv2.listDedicatedIpPools
199 .pages({})
200 .pipe(Stream.runCollect);
201 const poolNames = Array.from(pages).flatMap(
202 (page) => page.DedicatedIpPools ?? [],
203 );
204 const pools = yield* Effect.forEach(
205 poolNames,
206 (poolName) =>
207 getPool(poolName).pipe(
208 Effect.map((pool) =>
209 pool ? [{ poolName, scalingMode: pool.ScalingMode }] : [],
210 ),
211 ),
212 { concurrency: 2 },
213 );
214 return pools.flat();
215 }),
216
217 read: Effect.fn(function* ({ id, olds, output }) {
218 const name = output?.poolName ?? (yield* createName(id, olds ?? {}));
219 const found = yield* getPool(name);
220 if (!found) return undefined;
221 const attrs = { poolName: name, scalingMode: found.ScalingMode };
222 // A pool provisions billable dedicated IP capacity, and reconcile
223 // brands the ones it creates with internal tags. So existence at our
224 // deterministic name is NOT proof of ownership — a pre-existing pool
225 // someone else pays for must be adopted deliberately (`--adopt` /
226 // `adopt(true)`) rather than silently taken over.
227 const tags = yield* getPoolTags(name);
228 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
229 }),
230
231 diff: Effect.fn(function* ({ id, news, olds }) {
232 if (!isResolved(news)) return undefined;
233 const oldName = yield* createName(id, olds ?? {});
234 const newName = yield* createName(id, news ?? {});
235 const oldMode = olds?.scalingMode ?? DEFAULT_SCALING_MODE;
236 const newMode = news?.scalingMode ?? DEFAULT_SCALING_MODE;
237 // A rename replaces the pool. So does a MANAGED → STANDARD switch,
238 // which AWS has no API to perform in place (STANDARD → MANAGED is a
239 // supported in-place scaling change, handled in reconcile).
240 if (
241 oldName !== newName ||
242 (oldMode === "MANAGED" && newMode === "STANDARD")
243 ) {
244 return { action: "replace" } as const;
245 }
246 }),
247
248 reconcile: Effect.fn(function* ({ id, news, output }) {
249 const name = output?.poolName ?? (yield* createName(id, news));
250 const desiredMode = news.scalingMode ?? DEFAULT_SCALING_MODE;
251 const internalTags = yield* createInternalTags(id);
252 const desiredTags = { ...news.tags, ...internalTags };
253
254 // 1. OBSERVE — cloud state is authoritative.
255 let observed = yield* getPool(name);
256
257 if (observed === undefined) {
258 // 2. ENSURE — create with the desired scaling mode, branding the
259 // pool with internal tags. AlreadyExists is a race, not a
260 // failure.
261 yield* sesv2
262 .createDedicatedIpPool({
263 PoolName: name,
264 ScalingMode: desiredMode,
265 Tags: createTagsList(desiredTags),
266 })
267 .pipe(
268 Effect.catchTag("AlreadyExistsException", () =>
269 Effect.succeed({}),
270 ),
271 );
272 // Not always readable the instant create returns, and on the
273 // AlreadyExists race another writer may still be mid-create.
274 observed = yield* getPool(name).pipe(
275 Effect.repeat({
276 schedule: Schedule.spaced("1 second"),
277 until: (pool) => pool !== undefined,
278 times: 8,
279 }),
280 );
281 }
282
283 // 3. SYNC — the only in-place scaling change AWS supports is
284 // STANDARD → MANAGED (MANAGED → STANDARD is a replacement, gated
285 // in diff above).
286 if (observed?.ScalingMode !== desiredMode) {
287 yield* sesv2.putDedicatedIpPoolScalingAttributes({
288 PoolName: name,
289 ScalingMode: desiredMode,
290 });
291 }
292
293 // 3b. SYNC TAGS — brand the pool if it is not already ours. An
294 // adopted pool reaches this point without ever passing through
295 // the create above, so without this it would stay unbranded and
296 // `read` would keep reporting it Unowned on every later deploy.
297 // Diffed against OBSERVED cloud tags, never against olds.
298 const observedTags = yield* getPoolTags(name);
299 const { upsert, removed } = diffTags(observedTags, desiredTags);
300 if (upsert.length > 0 || removed.length > 0) {
301 const { accountId, region } = yield* AWSEnvironment.current;
302 const poolArn = dedicatedIpPoolArnOf(region, accountId, name);
303 if (upsert.length > 0) {
304 yield* sesv2.tagResource({ ResourceArn: poolArn, Tags: upsert });
305 }
306 if (removed.length > 0) {
307 yield* sesv2.untagResource({
308 ResourceArn: poolArn,
309 TagKeys: removed,
310 });
311 }
312 }
313
314 return { poolName: name, scalingMode: desiredMode };
315 }),
316
317 delete: Effect.fn(function* ({ output }) {
318 // deleteDedicatedIpPool is idempotent for a missing pool.
319 yield* sesv2
320 .deleteDedicatedIpPool({ PoolName: output.poolName })
321 .pipe(Effect.catchTag("NotFoundException", () => Effect.void));
322 }),
323 });
324 }),
325 );