1import * as mm from "@distilled.cloud/aws/mailmanager";
2import * as Effect from "effect/Effect";
3import * as Stream from "effect/Stream";
4import { Unowned } from "../../AdoptPolicy.ts";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import {
10 createInternalTags,
11 createTagsList,
12 hasAlchemyTags,
13} from "../../Tags.ts";
14import type { Providers } from "../Providers.ts";
15import {
16 readMailManagerTags,
17 retryWhileMailManagerConflict,
18 syncMailManagerTags,
19} from "./internal.ts";
20
21export interface AddressListProps {
22 /**
23 * Name of the address list. If omitted, a deterministic physical name is
24 * generated from the app, stage, and logical ID. Mail Manager has no
25 * update operation for address lists, so a rename replaces the list (its
26 * members are NOT carried over).
27 */
28 addressListName?: string;
29 /**
30 * Tags applied to the address list. Alchemy ownership tags are merged in
31 * automatically.
32 */
33 tags?: Record<string, string>;
34}
35
36export interface AddressList extends Resource<
37 "AWS.MailManager.AddressList",
38 AddressListProps,
39 {
40 /** Server-assigned ID of the address list. */
41 addressListId: string;
42 /** ARN of the address list. */
43 addressListArn: string;
44 /** Name of the address list. */
45 addressListName: string;
46 },
47 never,
48 Providers
49> {}
50
51/**
52 * An SES Mail Manager address list — a named set of email addresses that
53 * traffic policies and rule conditions can match against (allow lists,
54 * block lists, routing groups).
55 *
56 * The list itself is create-only config (name + tags); its members are
57 * data managed at runtime via the member capabilities
58 * ({@link RegisterMemberToAddressList}, {@link ListMembersOfAddressList},
59 * ...) or bulk import jobs.
60 * ### Creating Address Lists
61 * **Example:** Block List
62 * ```typescript
63 * import * as MailManager from "alchemy/AWS/MailManager";
64 *
65 * const blockList = yield* MailManager.AddressList("BlockList", {
66 * tags: { purpose: "smtp-block-list" },
67 * });
68 * ```
69 *
70 * ### Managing Members at Runtime
71 * **Example:** Register and List Members from a Lambda
72 * ```typescript
73 * // init — bind the member capabilities to the list
74 * const registerMember = yield* MailManager.RegisterMemberToAddressList(blockList);
75 * const listMembers = yield* MailManager.ListMembersOfAddressList(blockList);
76 *
77 * // runtime
78 * yield* registerMember({ Address: "spammer@example.com" });
79 * const { Addresses } = yield* listMembers({});
80 * ```
81 *
82 * @resource
83 */
84export const AddressList = Resource<AddressList>("AWS.MailManager.AddressList");
85
86export const AddressListProvider = () =>
87 Provider.effect(
88 AddressList,
89 Effect.gen(function* () {
90 const createName = Effect.fn(function* (
91 id: string,
92 props: { addressListName?: string },
93 ) {
94 return (
95 props.addressListName ??
96 (yield* createPhysicalName({ id, maxLength: 255 }))
97 );
98 });
99
100 const getById = (addressListId: string) =>
101 mm
102 .getAddressList({ AddressListId: addressListId })
103 .pipe(
104 Effect.catchTag("ResourceNotFoundException", () =>
105 Effect.succeed(undefined),
106 ),
107 );
108
109 // Address lists have no name-keyed Get — enumerate and match. The
110 // physical name is deterministic, so this recovers identity after a
111 // lost state write.
112 const findByName = (name: string) =>
113 mm.listAddressLists.pages({}).pipe(
114 Stream.runCollect,
115 Effect.map((chunk) =>
116 Array.from(chunk)
117 .flatMap((page) => page.AddressLists ?? [])
118 .find((l) => l.AddressListName === name),
119 ),
120 );
121
122 const observe = Effect.fn(function* (
123 output: AddressList["Attributes"] | undefined,
124 name: string,
125 ) {
126 if (output?.addressListId !== undefined) {
127 const found = yield* getById(output.addressListId);
128 if (found !== undefined) return found;
129 }
130 const summary = yield* findByName(name);
131 if (summary?.AddressListId === undefined) return undefined;
132 return yield* getById(summary.AddressListId);
133 });
134
135 const toAttrs = (list: mm.GetAddressListResponse) => ({
136 addressListId: list.AddressListId,
137 addressListArn: list.AddressListArn,
138 addressListName: list.AddressListName,
139 });
140
141 return AddressList.Provider.of({
142 stables: ["addressListId", "addressListArn"],
143
144 list: () =>
145 mm.listAddressLists.pages({}).pipe(
146 Stream.runCollect,
147 Effect.map((chunk) =>
148 Array.from(chunk)
149 .flatMap((page) => page.AddressLists ?? [])
150 .map((l) => ({
151 addressListId: l.AddressListId,
152 addressListArn: l.AddressListArn,
153 addressListName: l.AddressListName,
154 })),
155 ),
156 ),
157
158 read: Effect.fn(function* ({ id, olds, output }) {
159 const name =
160 output?.addressListName ?? (yield* createName(id, olds ?? {}));
161 const list = yield* observe(output, name);
162 if (list === undefined) return undefined;
163 const attrs = toAttrs(list);
164 const tags = yield* readMailManagerTags(attrs.addressListArn);
165 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
166 }),
167
168 // Mail Manager exposes no UpdateAddressList — a name change can only
169 // be realized by replacement.
170 diff: Effect.fn(function* ({ news, olds }) {
171 if (!isResolved(news)) return undefined;
172 if (olds === undefined) return undefined;
173 if (
174 news.addressListName !== undefined &&
175 olds.addressListName !== news.addressListName
176 ) {
177 return { action: "replace" } as const;
178 }
179 }),
180
181 reconcile: Effect.fn(function* ({ id, news, output, session }) {
182 const name = yield* createName(id, news);
183 const internalTags = yield* createInternalTags(id);
184 const desiredTags = { ...news.tags, ...internalTags };
185
186 // 1. OBSERVE — cloud state is authoritative; output is an id cache.
187 let list = yield* observe(output, name);
188
189 // 2. ENSURE — create if missing; a Conflict race re-observes by
190 // name instead of failing.
191 if (list === undefined) {
192 yield* session.note(`creating address list ${name}`);
193 const created = yield* mm
194 .createAddressList({
195 AddressListName: name,
196 Tags: createTagsList(desiredTags),
197 })
198 .pipe(
199 Effect.catchTag("ConflictException", () =>
200 Effect.succeed(undefined),
201 ),
202 );
203 list =
204 created !== undefined
205 ? yield* getById(created.AddressListId)
206 : yield* observe(undefined, name);
207 }
208 if (list === undefined) {
209 return yield* Effect.fail(
210 new Error(
211 `Mail Manager address list '${name}' not found after create`,
212 ),
213 );
214 }
215
216 // 3. SYNC TAGS — the only mutable aspect; diffed against observed
217 // cloud tags so adoption converges.
218 yield* syncMailManagerTags(list.AddressListArn, desiredTags);
219
220 yield* session.note(list.AddressListId);
221 return toAttrs(list);
222 }),
223
224 delete: Effect.fn(function* ({ output }) {
225 // DeleteAddressList's error union carries no not-found tag —
226 // deletion is natively idempotent. A list referenced by an
227 // in-flight import job reports Conflict — retry through it.
228 yield* retryWhileMailManagerConflict(
229 mm.deleteAddressList({ AddressListId: output.addressListId }),
230 ).pipe(Effect.asVoid);
231 }),
232 });
233 }),
234 );