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 * A subscription topic on a contact list. Pass the distilled shape directly:
21 * `TopicName` (the stable id), `DisplayName`, an optional `Description`, and a
22 * `DefaultSubscriptionStatus` of `"OPT_IN"` or `"OPT_OUT"`.
23 */
24export type ContactListTopic = sesv2.Topic;
25
26export interface ContactListProps {
27 /**
28 * Name of the contact list. May contain letters, numbers, dashes and
29 * underscores, up to 64 characters. If omitted, a deterministic physical
30 * name is generated from the app, stage, and logical ID. Changing the name
31 * replaces the contact list.
32 */
33 contactListName?: string;
34 /**
35 * A human-readable description of the contact list. Leave undefined to keep
36 * whatever SES currently has.
37 */
38 description?: string;
39 /**
40 * The interest topics contacts can subscribe to. Replaced wholesale when
41 * set — pass the full desired set. Leave undefined to keep whatever SES
42 * currently has.
43 */
44 topics?: ContactListTopic[];
45 /**
46 * Tags to apply to the contact list. Merged with internal Alchemy tags.
47 */
48 tags?: Record<string, string>;
49}
50
51export interface ContactList extends Resource<
52 "AWS.SES.ContactList",
53 ContactListProps,
54 {
55 /** Name of the contact list. */
56 contactListName: string;
57 /** ARN of the contact list. */
58 contactListArn: string;
59 },
60 never,
61 Providers
62> {}
63
64/**
65 * An Amazon SES v2 contact list — a named audience of email contacts with
66 * subscription topics, used with SES's list-management and unsubscribe
67 * handling.
68 *
69 * Add contacts with `SES.Contact`. Deleting the list deletes all of its
70 * contacts.
71 *
72 * SES allows only **one contact list per AWS account**, so renaming a list
73 * replaces it by deleting the old list (and its contacts) before creating the
74 * new one — a create-then-delete replacement would exceed the account limit.
75 * ### Creating Contact Lists
76 * **Example:** Basic Contact List
77 * ```typescript
78 * import * as SES from "alchemy/AWS/SES";
79 *
80 * const list = yield* SES.ContactList("Newsletter", {
81 * description: "Weekly product newsletter",
82 * });
83 * ```
84 *
85 * **Example:** Contact List with Topics
86 * ```typescript
87 * const list = yield* SES.ContactList("Newsletter", {
88 * topics: [
89 * {
90 * TopicName: "product-updates",
91 * DisplayName: "Product Updates",
92 * DefaultSubscriptionStatus: "OPT_IN",
93 * },
94 * {
95 * TopicName: "promotions",
96 * DisplayName: "Promotions",
97 * DefaultSubscriptionStatus: "OPT_OUT",
98 * },
99 * ],
100 * });
101 * ```
102 *
103 * **Example:** Contact List with Tags
104 * ```typescript
105 * const list = yield* SES.ContactList("Newsletter", {
106 * tags: { Team: "growth", Environment: "prod" },
107 * });
108 * ```
109 *
110 * ### Populating the List
111 * **Example:** Add Contacts to the List
112 * ```typescript
113 * const list = yield* SES.ContactList("Newsletter", {
114 * topics: [
115 * {
116 * TopicName: "product-updates",
117 * DisplayName: "Product Updates",
118 * DefaultSubscriptionStatus: "OPT_IN",
119 * },
120 * ],
121 * });
122 *
123 * for (const email of ["a@example.com", "b@example.com"]) {
124 * yield* SES.Contact(`Subscriber-${email}`, {
125 * contactListName: list.contactListName,
126 * emailAddress: email,
127 * topicPreferences: [
128 * { TopicName: "product-updates", SubscriptionStatus: "OPT_IN" },
129 * ],
130 * });
131 * }
132 * ```
133 *
134 * @resource
135 */
136export const ContactList = Resource<ContactList>("AWS.SES.ContactList");
137
138const toTagRecord = (
139 tags: ReadonlyArray<{ Key: string; Value: string }> | undefined,
140): Record<string, string> =>
141 Object.fromEntries((tags ?? []).map((tag) => [tag.Key, tag.Value]));
142
143const contactListArnOf = (region: string, accountId: string, name: string) =>
144 `arn:aws:ses:${region}:${accountId}:contact-list/${name}`;
145
146const sameTopics = (
147 a: ReadonlyArray<ContactListTopic> | undefined,
148 b: ReadonlyArray<ContactListTopic> | undefined,
149): boolean => {
150 const key = (topics: ReadonlyArray<ContactListTopic> | undefined) =>
151 JSON.stringify(
152 [...(topics ?? [])]
153 .map((t) => ({
154 TopicName: t.TopicName,
155 DisplayName: t.DisplayName,
156 Description: t.Description,
157 DefaultSubscriptionStatus: t.DefaultSubscriptionStatus,
158 }))
159 .sort((x, y) => x.TopicName.localeCompare(y.TopicName)),
160 );
161 return key(a) === key(b);
162};
163
164export const ContactListProvider = () =>
165 Provider.effect(
166 ContactList,
167 Effect.gen(function* () {
168 const createName = Effect.fn(function* (
169 id: string,
170 props: Pick<ContactListProps, "contactListName">,
171 ) {
172 return (
173 props.contactListName ??
174 (yield* createPhysicalName({ id, maxLength: 64 }))
175 );
176 });
177
178 const getList = Effect.fn(function* (name: string) {
179 return yield* sesv2
180 .getContactList({ ContactListName: name })
181 .pipe(
182 Effect.catchTag("NotFoundException", () =>
183 Effect.succeed(undefined),
184 ),
185 );
186 });
187
188 return ContactList.Provider.of({
189 stables: ["contactListName", "contactListArn"],
190
191 list: Effect.fn(function* () {
192 const { accountId, region } = yield* AWSEnvironment.current;
193 const pages = yield* sesv2.listContactLists
194 .pages({})
195 .pipe(Stream.runCollect);
196 return Array.from(pages)
197 .flatMap((page) => page.ContactLists ?? [])
198 .flatMap((entry) =>
199 entry.ContactListName
200 ? [
201 {
202 contactListName: entry.ContactListName,
203 contactListArn: contactListArnOf(
204 region,
205 accountId,
206 entry.ContactListName,
207 ),
208 },
209 ]
210 : [],
211 );
212 }),
213
214 read: Effect.fn(function* ({ id, olds, output }) {
215 const { accountId, region } = yield* AWSEnvironment.current;
216 const name =
217 output?.contactListName ?? (yield* createName(id, olds ?? {}));
218 const found = yield* getList(name);
219 if (!found) return undefined;
220 const attrs = {
221 contactListName: name,
222 contactListArn: contactListArnOf(region, accountId, name),
223 };
224 const tags = toTagRecord(found.Tags);
225 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
226 }),
227
228 diff: Effect.fn(function* ({ id, news, olds }) {
229 if (!isResolved(news)) return undefined;
230 const oldName = yield* createName(id, olds ?? {});
231 const newName = yield* createName(id, news ?? {});
232 if (oldName !== newName) {
233 // Only one contact list is allowed per account, so the old list
234 // has to go before the new one can be created.
235 return { action: "replace", deleteFirst: true } as const;
236 }
237 }),
238
239 reconcile: Effect.fn(function* ({ id, news, output, session }) {
240 const { accountId, region } = yield* AWSEnvironment.current;
241 const name = output?.contactListName ?? (yield* createName(id, news));
242 const contactListArn = contactListArnOf(region, accountId, name);
243 const internalTags = yield* createInternalTags(id);
244 const desiredTags = { ...news.tags, ...internalTags };
245
246 // 1. OBSERVE — cloud state is authoritative.
247 let observed = yield* getList(name);
248
249 if (observed === undefined) {
250 // 2. ENSURE — create with the full desired set; AlreadyExists is a
251 // race → converge via update.
252 yield* sesv2
253 .createContactList({
254 ContactListName: name,
255 Description: news.description,
256 Topics: news.topics,
257 Tags: createTagsList(desiredTags),
258 })
259 .pipe(
260 Effect.catchTag("AlreadyExistsException", () =>
261 sesv2.updateContactList({
262 ContactListName: name,
263 Description: news.description,
264 Topics: news.topics,
265 }),
266 ),
267 );
268 // getList tolerates NotFound; the list is not always readable the
269 // instant create returns, and on the AlreadyExists race another
270 // writer may still be mid-create.
271 observed = (yield* getList(name).pipe(
272 Effect.repeat({
273 schedule: Schedule.spaced("1 second"),
274 until: (list) => list !== undefined,
275 times: 8,
276 }),
277 ))!;
278 } else {
279 // 3. SYNC — only the aspects the caller manages. An omitted prop
280 // keeps whatever SES currently has, matching every sibling
281 // resource in this service; updateContactList replaces each
282 // field it is given, so observed values are echoed back for the
283 // fields we are not managing.
284 const managesDescription = news.description !== undefined;
285 const managesTopics = news.topics !== undefined;
286 const descriptionChanged =
287 managesDescription && observed.Description !== news.description;
288 const topicsChanged =
289 managesTopics && !sameTopics(observed.Topics, news.topics);
290
291 if (descriptionChanged || topicsChanged) {
292 yield* sesv2.updateContactList({
293 ContactListName: name,
294 Description: managesDescription
295 ? news.description
296 : observed.Description,
297 Topics: managesTopics ? news.topics : observed.Topics,
298 });
299 }
300 }
301
302 // 3b. SYNC TAGS — diff against OBSERVED cloud tags so adoption
303 // converges.
304 const observedTags = toTagRecord(observed.Tags);
305 const { upsert, removed } = diffTags(observedTags, desiredTags);
306 if (upsert.length > 0) {
307 yield* sesv2.tagResource({
308 ResourceArn: contactListArn,
309 Tags: upsert,
310 });
311 }
312 if (removed.length > 0) {
313 yield* sesv2.untagResource({
314 ResourceArn: contactListArn,
315 TagKeys: removed,
316 });
317 }
318
319 yield* session.note(contactListArn);
320 return { contactListName: name, contactListArn };
321 }),
322
323 delete: Effect.fn(function* ({ output }) {
324 // deleteContactList removes the list and all of its contacts, and is
325 // idempotent for a missing list.
326 yield* sesv2
327 .deleteContactList({ ContactListName: output.contactListName })
328 .pipe(Effect.catchTag("NotFoundException", () => Effect.void));
329 }),
330 });
331 }),
332 );