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
9export interface TenantResourceAssociationProps {
10 /**
11 * Name of the tenant to associate the resource with. Typically the
12 * `tenantName` output of a `SES.Tenant`. Changing it replaces the
13 * association.
14 */
15 tenantName: string;
16 /**
17 * ARN of the resource to associate — an email identity, configuration set,
18 * or email template. Changing it replaces the association.
19 */
20 resourceArn: string;
21}
22
23export interface TenantResourceAssociation extends Resource<
24 "AWS.SES.TenantResourceAssociation",
25 TenantResourceAssociationProps,
26 {
27 /** Name of the tenant. */
28 tenantName: string;
29 /** ARN of the associated resource. */
30 resourceArn: string;
31 },
32 never,
33 Providers
34> {}
35
36/**
37 * An association between an Amazon SES v2 tenant and a resource — an email
38 * identity, configuration set, or email template. Once associated, the
39 * resource can be used when sending email on behalf of the tenant. A single
40 * resource can be associated with multiple tenants.
41 *
42 * This is an existence-only link with no mutable properties: changing either
43 * the tenant or the resource replaces the association.
44 * ### Associating Resources
45 * **Example:** Associate an Email Identity with a Tenant
46 * ```typescript
47 * import * as SES from "alchemy/AWS/SES";
48 *
49 * const tenant = yield* SES.Tenant("CustomerA", {});
50 * const identity = yield* SES.EmailIdentity("Sender", {
51 * emailIdentity: "sender@example.com",
52 * });
53 * const association = yield* SES.TenantResourceAssociation("SenderLink", {
54 * tenantName: tenant.tenantName,
55 * resourceArn: identity.identityArn,
56 * });
57 * ```
58 *
59 * **Example:** Associate a Configuration Set
60 * ```typescript
61 * const configSet = yield* SES.ConfigurationSet("AcmeTracking", {});
62 * yield* SES.TenantResourceAssociation("ConfigSetLink", {
63 * tenantName: tenant.tenantName,
64 * resourceArn: configSet.configurationSetArn,
65 * });
66 * ```
67 *
68 * **Example:** Associate an Email Template
69 * ```typescript
70 * const template = yield* SES.EmailTemplate("Welcome", {
71 * subject: "Welcome, {{name}}!",
72 * text: "Thanks for signing up, {{name}}.",
73 * });
74 * yield* SES.TenantResourceAssociation("TemplateLink", {
75 * tenantName: tenant.tenantName,
76 * resourceArn: template.templateArn,
77 * });
78 * ```
79 *
80 * **Example:** Share One Identity Across Two Tenants
81 * ```typescript
82 * // A resource can belong to any number of tenants.
83 * const acme = yield* SES.Tenant("Acme", {});
84 * const globex = yield* SES.Tenant("Globex", {});
85 *
86 * yield* SES.TenantResourceAssociation("AcmeSender", {
87 * tenantName: acme.tenantName,
88 * resourceArn: identity.identityArn,
89 * });
90 * yield* SES.TenantResourceAssociation("GlobexSender", {
91 * tenantName: globex.tenantName,
92 * resourceArn: identity.identityArn,
93 * });
94 * ```
95 *
96 * @resource
97 */
98export const TenantResourceAssociation = Resource<TenantResourceAssociation>(
99 "AWS.SES.TenantResourceAssociation",
100);
101
102export const TenantResourceAssociationProvider = () =>
103 Provider.effect(
104 TenantResourceAssociation,
105 Effect.gen(function* () {
106 // Every resource associated with the tenant, or undefined when the
107 // tenant itself is gone.
108 const listResources = Effect.fn(function* (tenantName: string) {
109 const pages = yield* sesv2.listTenantResources
110 .pages({ TenantName: tenantName })
111 .pipe(
112 Stream.runCollect,
113 Effect.catchTag("NotFoundException", () =>
114 Effect.succeed(undefined),
115 ),
116 );
117 if (pages === undefined) return undefined;
118 return Array.from(pages).flatMap((page) => page.TenantResources ?? []);
119 });
120
121 const isAssociated = Effect.fn(function* (
122 tenantName: string,
123 resourceArn: string,
124 ) {
125 // A missing tenant means the association cannot exist.
126 const resources = yield* listResources(tenantName);
127 return (resources ?? []).some(
128 (resource) => resource.ResourceArn === resourceArn,
129 );
130 });
131
132 return TenantResourceAssociation.Provider.of({
133 // Deleting an association requires BOTH ends to still exist: the
134 // tenant, and the resource it points at — SES refuses to delete a
135 // configuration set that still has tenant associations ("Cannot
136 // delete <arn> because it has tenant associations"). So every
137 // associable type must outlive the associations nuke tears down.
138 nuke: {
139 dependsOn: [
140 "AWS.SES.Tenant",
141 "AWS.SES.ConfigurationSet",
142 "AWS.SES.EmailIdentity",
143 "AWS.SES.EmailTemplate",
144 ],
145 },
146 stables: ["tenantName", "resourceArn"],
147
148 // Associations are keyed by their parent tenant, so enumeration walks
149 // every tenant and pages through its resources. listResources already
150 // treats a vanished tenant as "no associations".
151 list: Effect.fn(function* () {
152 const pages = yield* sesv2.listTenants
153 .pages({})
154 .pipe(Stream.runCollect);
155 const tenantNames = Array.from(pages)
156 .flatMap((page) => page.Tenants ?? [])
157 .flatMap((tenant) =>
158 tenant.TenantName ? [tenant.TenantName] : [],
159 );
160 const nested = yield* Effect.forEach(
161 tenantNames,
162 (tenantName) =>
163 listResources(tenantName).pipe(
164 Effect.map((resources) =>
165 (resources ?? []).flatMap((resource) =>
166 resource.ResourceArn
167 ? [{ tenantName, resourceArn: resource.ResourceArn }]
168 : [],
169 ),
170 ),
171 ),
172 { concurrency: 2 },
173 );
174 return nested.flat();
175 }),
176
177 read: Effect.fn(function* ({ olds, output }) {
178 const tenantName = output?.tenantName ?? olds?.tenantName;
179 const resourceArn = output?.resourceArn ?? olds?.resourceArn;
180 if (tenantName === undefined || resourceArn === undefined) {
181 return undefined;
182 }
183 const found = yield* isAssociated(tenantName, resourceArn);
184 return found ? { tenantName, resourceArn } : undefined;
185 }),
186
187 diff: Effect.fn(function* ({ news, olds }) {
188 if (!isResolved(news)) return undefined;
189 if (
190 news.tenantName !== olds.tenantName ||
191 news.resourceArn !== olds.resourceArn
192 ) {
193 return { action: "replace" } as const;
194 }
195 }),
196
197 reconcile: Effect.fn(function* ({ news, output }) {
198 const tenantName = output?.tenantName ?? news.tenantName;
199 const resourceArn = output?.resourceArn ?? news.resourceArn;
200
201 // OBSERVE — associations have no update API, so reconcile is
202 // create-only.
203 const found = yield* isAssociated(tenantName, resourceArn);
204
205 // ENSURE — create if missing; AlreadyExists is a race, not a failure.
206 if (!found) {
207 yield* sesv2
208 .createTenantResourceAssociation({
209 TenantName: tenantName,
210 ResourceArn: resourceArn,
211 })
212 .pipe(
213 Effect.catchTag("AlreadyExistsException", () =>
214 Effect.succeed({}),
215 ),
216 );
217 }
218
219 return { tenantName, resourceArn };
220 }),
221
222 delete: Effect.fn(function* ({ output }) {
223 // deleteTenantResourceAssociation is idempotent; a missing tenant or
224 // association means the link is already gone.
225 yield* sesv2
226 .deleteTenantResourceAssociation({
227 TenantName: output.tenantName,
228 ResourceArn: output.resourceArn,
229 })
230 .pipe(Effect.catchTag("NotFoundException", () => Effect.void));
231 }),
232 });
233 }),
234 );