1import * as sesv2 from "@distilled.cloud/aws/sesv2";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import type { Providers } from "../Providers.ts";
8
9/**
10 * A contact's subscription preference for a single topic. Pass the distilled
11 * shape directly: `TopicName` and a `SubscriptionStatus` of `"OPT_IN"` or
12 * `"OPT_OUT"`.
13 */
14export type ContactTopicPreference = sesv2.TopicPreference;
15
16export interface ContactProps {
17 /**
18 * Name of the contact list this contact belongs to. Typically the
19 * `contactListName` output of a `SES.ContactList`. Changing it replaces the
20 * contact.
21 */
22 contactListName: string;
23 /**
24 * The contact's email address — the stable identifier within the list.
25 * Changing it replaces the contact.
26 */
27 emailAddress: string;
28 /**
29 * The contact's per-topic subscription preferences. Replaced wholesale when
30 * set — pass the full desired set. Leave undefined to keep whatever SES
31 * currently has.
32 */
33 topicPreferences?: ContactTopicPreference[];
34 /**
35 * Whether the contact is unsubscribed from all topics. Leave undefined to
36 * keep SES's current setting.
37 * @default false
38 */
39 unsubscribeAll?: boolean;
40 /**
41 * Arbitrary application metadata attached to the contact. Serialized to the
42 * JSON string SES stores; equivalent representations (key order, whitespace)
43 * are ignored when detecting drift. Leave undefined to keep whatever SES
44 * currently has.
45 */
46 attributes?: Record<string, unknown>;
47}
48
49export interface Contact extends Resource<
50 "AWS.SES.Contact",
51 ContactProps,
52 {
53 /** Name of the contact list the contact belongs to. */
54 contactListName: string;
55 /** The contact's email address. */
56 emailAddress: string;
57 },
58 never,
59 Providers
60> {}
61
62/**
63 * An Amazon SES v2 contact — a single email address on a `SES.ContactList`,
64 * with its own topic subscription preferences and unsubscribe state.
65 * ### Adding Contacts
66 * **Example:** Basic Contact
67 * ```typescript
68 * import * as SES from "alchemy/AWS/SES";
69 *
70 * const list = yield* SES.ContactList("Newsletter", {});
71 * const contact = yield* SES.Contact("Subscriber", {
72 * contactListName: list.contactListName,
73 * emailAddress: "reader@example.com",
74 * });
75 * ```
76 *
77 * **Example:** Contact with Topic Preferences
78 * ```typescript
79 * const contact = yield* SES.Contact("Subscriber", {
80 * contactListName: list.contactListName,
81 * emailAddress: "reader@example.com",
82 * topicPreferences: [
83 * { TopicName: "product-updates", SubscriptionStatus: "OPT_IN" },
84 * { TopicName: "promotions", SubscriptionStatus: "OPT_OUT" },
85 * ],
86 * });
87 * ```
88 *
89 * **Example:** Unsubscribe a Contact from Everything
90 * ```typescript
91 * // unsubscribeAll overrides every per-topic preference.
92 * const contact = yield* SES.Contact("Subscriber", {
93 * contactListName: list.contactListName,
94 * emailAddress: "reader@example.com",
95 * unsubscribeAll: true,
96 * });
97 * ```
98 *
99 * ### Application Metadata
100 * **Example:** Attach Your Own Data to a Contact
101 * ```typescript
102 * // Serialized to the JSON string SES stores; re-ordering the keys is not a
103 * // change, so this does not churn on every deploy.
104 * const contact = yield* SES.Contact("Subscriber", {
105 * contactListName: list.contactListName,
106 * emailAddress: "reader@example.com",
107 * attributes: { plan: "pro", signupSource: "docs" },
108 * });
109 * ```
110 *
111 * @resource
112 */
113export const Contact = Resource<Contact>("AWS.SES.Contact");
114
115// SES stores contact metadata as an opaque JSON string. Serialize only at the
116// API boundary, and compare on a canonical (sorted-key) form so a re-ordered
117// object is not mistaken for a change.
118const stringifyAttributes = (
119 attributes: Record<string, unknown> | undefined,
120): string | undefined =>
121 attributes === undefined ? undefined : JSON.stringify(attributes);
122
123const normalizeAttributes = (
124 attributes: Record<string, unknown> | string | undefined,
125): string | undefined => {
126 if (attributes === undefined) return undefined;
127 let value: unknown = attributes;
128 if (typeof attributes === "string") {
129 try {
130 value = JSON.parse(attributes) as unknown;
131 } catch {
132 // Not JSON (a hand-written contact, say) — compare the raw string.
133 return attributes;
134 }
135 }
136 const canonical = (input: unknown): unknown =>
137 Array.isArray(input)
138 ? input.map(canonical)
139 : input !== null && typeof input === "object"
140 ? Object.fromEntries(
141 Object.entries(input as Record<string, unknown>)
142 .sort(([a], [b]) => a.localeCompare(b))
143 .map(([key, val]) => [key, canonical(val)]),
144 )
145 : input;
146 return JSON.stringify(canonical(value));
147};
148
149const samePreferences = (
150 a: ReadonlyArray<ContactTopicPreference> | undefined,
151 b: ReadonlyArray<ContactTopicPreference> | undefined,
152): boolean => {
153 const key = (prefs: ReadonlyArray<ContactTopicPreference> | undefined) =>
154 JSON.stringify(
155 [...(prefs ?? [])]
156 .map((p) => ({
157 TopicName: p.TopicName,
158 SubscriptionStatus: p.SubscriptionStatus,
159 }))
160 .sort((x, y) => x.TopicName.localeCompare(y.TopicName)),
161 );
162 return key(a) === key(b);
163};
164
165export const ContactProvider = () =>
166 Provider.effect(
167 Contact,
168 Effect.gen(function* () {
169 const getContact = Effect.fn(function* (
170 contactListName: string,
171 emailAddress: string,
172 ) {
173 return yield* sesv2
174 .getContact({
175 ContactListName: contactListName,
176 EmailAddress: emailAddress,
177 })
178 .pipe(
179 Effect.catchTag("NotFoundException", () =>
180 Effect.succeed(undefined),
181 ),
182 );
183 });
184
185 return Contact.Provider.of({
186 // Deleting a contact requires its parent list to still exist, so the
187 // list must outlive every contact nuke tears down.
188 nuke: { dependsOn: ["AWS.SES.ContactList"] },
189 stables: ["contactListName", "emailAddress"],
190
191 // Contacts are keyed by their parent list, so enumeration walks every
192 // contact list and pages through its contacts. SES allows one contact
193 // list per account, so the outer page is at most one entry.
194 list: Effect.fn(function* () {
195 const listPages = yield* sesv2.listContactLists
196 .pages({})
197 .pipe(Stream.runCollect);
198 const contactListNames = Array.from(listPages)
199 .flatMap((page) => page.ContactLists ?? [])
200 .flatMap((entry) =>
201 entry.ContactListName ? [entry.ContactListName] : [],
202 );
203 const nested = yield* Effect.forEach(
204 contactListNames,
205 (contactListName) =>
206 sesv2.listContacts
207 .pages({ ContactListName: contactListName })
208 .pipe(
209 Stream.runCollect,
210 Effect.map((pages) =>
211 Array.from(pages)
212 .flatMap((page) => page.Contacts ?? [])
213 .flatMap((contact) =>
214 contact.EmailAddress
215 ? [
216 {
217 contactListName,
218 emailAddress: contact.EmailAddress,
219 },
220 ]
221 : [],
222 ),
223 ),
224 // The list can be deleted between the two calls.
225 Effect.catchTag("NotFoundException", () =>
226 Effect.succeed(
227 [] as {
228 contactListName: string;
229 emailAddress: string;
230 }[],
231 ),
232 ),
233 ),
234 { concurrency: 2 },
235 );
236 return nested.flat();
237 }),
238
239 read: Effect.fn(function* ({ olds, output }) {
240 const contactListName =
241 output?.contactListName ?? olds?.contactListName;
242 const emailAddress = output?.emailAddress ?? olds?.emailAddress;
243 if (contactListName === undefined || emailAddress === undefined) {
244 return undefined;
245 }
246 const found = yield* getContact(contactListName, emailAddress);
247 // Contacts carry no tags, so existence at the (list, email) key is
248 // treated as ownership.
249 return found ? { contactListName, emailAddress } : undefined;
250 }),
251
252 diff: Effect.fn(function* ({ news, olds }) {
253 if (!isResolved(news)) return undefined;
254 if (
255 news.contactListName !== olds.contactListName ||
256 news.emailAddress !== olds.emailAddress
257 ) {
258 return { action: "replace" } as const;
259 }
260 }),
261
262 reconcile: Effect.fn(function* ({ news, output }) {
263 const contactListName =
264 output?.contactListName ?? news.contactListName;
265 const emailAddress = output?.emailAddress ?? news.emailAddress;
266 const attributesData = yield* Effect.sync(() =>
267 stringifyAttributes(news.attributes),
268 );
269
270 // 1. OBSERVE — cloud state is authoritative.
271 const observed = yield* getContact(contactListName, emailAddress);
272
273 if (observed === undefined) {
274 // 2. ENSURE — create; AlreadyExists is a race → converge via update.
275 yield* sesv2
276 .createContact({
277 ContactListName: contactListName,
278 EmailAddress: emailAddress,
279 TopicPreferences: news.topicPreferences,
280 UnsubscribeAll: news.unsubscribeAll,
281 AttributesData: attributesData,
282 })
283 .pipe(
284 Effect.catchTag("AlreadyExistsException", () =>
285 sesv2.updateContact({
286 ContactListName: contactListName,
287 EmailAddress: emailAddress,
288 TopicPreferences: news.topicPreferences,
289 UnsubscribeAll: news.unsubscribeAll,
290 AttributesData: attributesData,
291 }),
292 ),
293 );
294 } else {
295 // 3. SYNC — only the aspects the caller manages. An omitted prop
296 // keeps whatever SES currently has, matching every sibling
297 // resource in this service; updateContact replaces each field
298 // it is given, so observed values are echoed back for the
299 // fields we are not managing.
300 const managesPreferences = news.topicPreferences !== undefined;
301 const managesUnsubscribe = news.unsubscribeAll !== undefined;
302 const managesAttributes = news.attributes !== undefined;
303
304 const preferencesChanged =
305 managesPreferences &&
306 !samePreferences(
307 observed.TopicPreferences,
308 news.topicPreferences,
309 );
310 const unsubscribeChanged =
311 managesUnsubscribe &&
312 (observed.UnsubscribeAll ?? false) !== news.unsubscribeAll;
313 const attributesChanged =
314 managesAttributes &&
315 (yield* Effect.sync(
316 () =>
317 normalizeAttributes(observed.AttributesData) !==
318 normalizeAttributes(news.attributes),
319 ));
320
321 if (preferencesChanged || unsubscribeChanged || attributesChanged) {
322 yield* sesv2.updateContact({
323 ContactListName: contactListName,
324 EmailAddress: emailAddress,
325 TopicPreferences: managesPreferences
326 ? news.topicPreferences
327 : observed.TopicPreferences,
328 UnsubscribeAll: managesUnsubscribe
329 ? news.unsubscribeAll
330 : observed.UnsubscribeAll,
331 AttributesData: managesAttributes
332 ? attributesData
333 : observed.AttributesData,
334 });
335 }
336 }
337
338 return { contactListName, emailAddress };
339 }),
340
341 delete: Effect.fn(function* ({ output }) {
342 // deleteContact is idempotent for a missing contact; a missing list
343 // means the contact is already gone.
344 yield* sesv2
345 .deleteContact({
346 ContactListName: output.contactListName,
347 EmailAddress: output.emailAddress,
348 })
349 .pipe(Effect.catchTag("NotFoundException", () => Effect.void));
350 }),
351 });
352 }),
353 );