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 type { Providers } from "../Providers.ts";
17import type { SuppressionListReason } from "./ConfigurationSet.ts";
18
19/**
20 * The scope of the tenant's suppression list: `ACCOUNT` shares the
21 * account-level suppression list, `TENANT` maintains a separate list scoped to
22 * this tenant.
23 */
24export type SuppressionListScope = sesv2.SuppressionListScope;
25
26export interface TenantSuppressionSettings {
27 /**
28 * The bounce/complaint reasons for which SES adds destinations to the
29 * tenant's suppression list.
30 */
31 reasons: SuppressionListReason[];
32 /**
33 * Whether the tenant uses the account-level suppression list (`ACCOUNT`) or
34 * maintains its own tenant-scoped list (`TENANT`).
35 */
36 scope: SuppressionListScope;
37}
38
39export interface TenantProps {
40 /**
41 * Name of the tenant. If omitted, a deterministic physical name is generated
42 * from the app, stage, and logical ID. Changing the name replaces the
43 * tenant.
44 */
45 tenantName?: string;
46 /**
47 * The tenant's suppression list configuration, synced in place via
48 * `putTenantSuppressionAttributes`. Leave undefined to keep SES's current
49 * setting.
50 *
51 * `reasons` and `scope` are both required together: SES rejects a
52 * suppression update carrying only one of them
53 * (`BadRequestException: SuppressedReasons cannot be specified without
54 * SuppressionScope`, and vice versa).
55 */
56 suppression?: TenantSuppressionSettings;
57 /**
58 * Tags to apply to the tenant. Merged with internal Alchemy tags.
59 */
60 tags?: Record<string, string>;
61}
62
63export interface Tenant extends Resource<
64 "AWS.SES.Tenant",
65 TenantProps,
66 {
67 /** Name of the tenant. */
68 tenantName: string;
69 /** Opaque tenant identifier assigned by SES. */
70 tenantId: string;
71 /** ARN of the tenant. */
72 tenantArn: string;
73 },
74 never,
75 Providers
76> {}
77
78/**
79 * An Amazon SES v2 tenant — a logical container that groups related SES
80 * resources (email identities, configuration sets, templates) together, each
81 * with its own reputation metrics, sending status, and optional
82 * tenant-scoped suppression list. Useful for isolating email sending across
83 * customers or business units within a single SES account.
84 *
85 * Associate resources with a tenant using `SES.TenantResourceAssociation`.
86 * Deleting the tenant removes its resource associations but leaves the
87 * underlying resources in place.
88 * ### Creating Tenants
89 * **Example:** Basic Tenant
90 * ```typescript
91 * import * as SES from "alchemy/AWS/SES";
92 *
93 * const tenant = yield* SES.Tenant("CustomerA", {});
94 * ```
95 *
96 * **Example:** Tenant with a Scoped Suppression List
97 * ```typescript
98 * // SES requires the reasons and the scope together, so they travel as one
99 * // prop rather than two independently-optional ones.
100 * const tenant = yield* SES.Tenant("CustomerA", {
101 * suppression: { reasons: ["BOUNCE", "COMPLAINT"], scope: "TENANT" },
102 * });
103 * ```
104 *
105 * **Example:** Tenant with Tags
106 * ```typescript
107 * const tenant = yield* SES.Tenant("CustomerA", {
108 * tags: { Customer: "acme", CostCenter: "growth" },
109 * });
110 * ```
111 *
112 * ### Associating Resources
113 * **Example:** Give the Tenant an Identity, Config Set, and Template
114 * ```typescript
115 * const tenant = yield* SES.Tenant("CustomerA", {});
116 * const identity = yield* SES.EmailIdentity("Sender", {
117 * emailIdentity: "mail.acme.example.com",
118 * });
119 * const configSet = yield* SES.ConfigurationSet("AcmeTracking", {});
120 *
121 * // A resource must be associated before the tenant can send with it.
122 * yield* SES.TenantResourceAssociation("AcmeIdentity", {
123 * tenantName: tenant.tenantName,
124 * resourceArn: identity.identityArn,
125 * });
126 * yield* SES.TenantResourceAssociation("AcmeConfigSet", {
127 * tenantName: tenant.tenantName,
128 * resourceArn: configSet.configurationSetArn,
129 * });
130 * ```
131 *
132 * ### Tenant Suppression Lists
133 * **Example:** Read and Write the Tenant's Own Suppression List
134 * ```typescript
135 * // With scope "TENANT" the list is separate from the account's.
136 * const tenant = yield* SES.Tenant("CustomerA", {
137 * suppression: { reasons: ["BOUNCE", "COMPLAINT"], scope: "TENANT" },
138 * });
139 *
140 * // init — account-level bindings, scoped per call via TenantName
141 * const suppress = yield* SES.PutSuppressedDestination();
142 * const listSuppressed = yield* SES.ListSuppressedDestinations();
143 *
144 * // runtime
145 * yield* suppress({
146 * EmailAddress: "hard-bounce@example.com",
147 * Reason: "BOUNCE",
148 * TenantName: yield* tenant.tenantName,
149 * });
150 * const { SuppressedDestinationSummaries } = yield* listSuppressed({
151 * TenantName: yield* tenant.tenantName,
152 * });
153 * ```
154 *
155 * @resource
156 */
157export const Tenant = Resource<Tenant>("AWS.SES.Tenant");
158
159const toTagRecord = (
160 tags: ReadonlyArray<{ Key: string; Value: string }> | undefined,
161): Record<string, string> =>
162 Object.fromEntries((tags ?? []).map((tag) => [tag.Key, tag.Value]));
163
164const sameReasons = (
165 a: ReadonlyArray<sesv2.SuppressionListReason> | undefined,
166 b: ReadonlyArray<sesv2.SuppressionListReason> | undefined,
167): boolean => {
168 const key = (
169 reasons: ReadonlyArray<sesv2.SuppressionListReason> | undefined,
170 ) => JSON.stringify([...(reasons ?? [])].sort());
171 return key(a) === key(b);
172};
173
174export const TenantProvider = () =>
175 Provider.effect(
176 Tenant,
177 Effect.gen(function* () {
178 const createName = Effect.fn(function* (
179 id: string,
180 props: Pick<TenantProps, "tenantName">,
181 ) {
182 return (
183 props.tenantName ?? (yield* createPhysicalName({ id, maxLength: 64 }))
184 );
185 });
186
187 const getTenant = Effect.fn(function* (name: string) {
188 return yield* sesv2.getTenant({ TenantName: name }).pipe(
189 Effect.map((response) => response.Tenant),
190 Effect.catchTag("NotFoundException", () => Effect.succeed(undefined)),
191 );
192 });
193
194 return Tenant.Provider.of({
195 stables: ["tenantName", "tenantId", "tenantArn"],
196
197 // Account-scoped: enumerate every tenant so leaked test resources are
198 // cleaned by nuke.
199 list: Effect.fn(function* () {
200 const pages = yield* sesv2.listTenants
201 .pages({})
202 .pipe(Stream.runCollect);
203 return Array.from(pages)
204 .flatMap((page) => page.Tenants ?? [])
205 .flatMap((entry) =>
206 entry.TenantName && entry.TenantId && entry.TenantArn
207 ? [
208 {
209 tenantName: entry.TenantName,
210 tenantId: entry.TenantId,
211 tenantArn: entry.TenantArn,
212 },
213 ]
214 : [],
215 );
216 }),
217
218 read: Effect.fn(function* ({ id, olds, output }) {
219 const name =
220 output?.tenantName ?? (yield* createName(id, olds ?? {}));
221 const found = yield* getTenant(name);
222 if (!found || !found.TenantId || !found.TenantArn) return undefined;
223 const attrs = {
224 tenantName: name,
225 tenantId: found.TenantId,
226 tenantArn: found.TenantArn,
227 };
228 const tags = toTagRecord(found.Tags);
229 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
230 }),
231
232 diff: Effect.fn(function* ({ id, news, olds }) {
233 if (!isResolved(news)) return undefined;
234 const oldName = yield* createName(id, olds ?? {});
235 const newName = yield* createName(id, news ?? {});
236 if (oldName !== newName) {
237 return { action: "replace" } as const;
238 }
239 }),
240
241 reconcile: Effect.fn(function* ({ id, news, output, session }) {
242 const name = output?.tenantName ?? (yield* createName(id, news));
243 const internalTags = yield* createInternalTags(id);
244 const desiredTags = { ...news.tags, ...internalTags };
245
246 // 1. OBSERVE — cloud state is authoritative.
247 let observed = yield* getTenant(name);
248
249 if (observed === undefined) {
250 // 2. ENSURE — create with the full desired set. AlreadyExists is a
251 // race → fall through to the sync steps to converge.
252 yield* sesv2
253 .createTenant({
254 TenantName: name,
255 Tags: createTagsList(desiredTags),
256 SuppressionAttributes: news.suppression
257 ? {
258 SuppressedReasons: news.suppression.reasons,
259 SuppressionScope: news.suppression.scope,
260 }
261 : undefined,
262 })
263 .pipe(
264 Effect.catchTag("AlreadyExistsException", () =>
265 Effect.succeed({}),
266 ),
267 );
268 // Not always readable the instant create returns, and on the
269 // AlreadyExists race another writer may still be mid-create.
270 observed = yield* getTenant(name).pipe(
271 Effect.repeat({
272 schedule: Schedule.spaced("1 second"),
273 until: (tenant) => tenant !== undefined,
274 times: 8,
275 }),
276 );
277 }
278
279 if (
280 observed === undefined ||
281 !observed.TenantId ||
282 !observed.TenantArn
283 ) {
284 return yield* Effect.fail(
285 new Error(`SES tenant ${name} was not found after create`),
286 );
287 }
288 const tenantArn = observed.TenantArn;
289
290 // 3. SYNC SUPPRESSION — diff observed suppression against desired and
291 // apply only on a real delta.
292 //
293 // SES requires both members together — a put carrying only one
294 // fails with BadRequestException ("SuppressedReasons cannot be
295 // specified without SuppressionScope", and the mirror image). The
296 // prop nests them so that invalid state is unrepresentable and
297 // the put below always carries both.
298 const observedReasons =
299 observed.SuppressionAttributes?.SuppressedReasons;
300 const observedScope =
301 observed.SuppressionAttributes?.SuppressionScope;
302 if (
303 news.suppression !== undefined &&
304 (!sameReasons(observedReasons, news.suppression.reasons) ||
305 observedScope !== news.suppression.scope)
306 ) {
307 yield* sesv2.putTenantSuppressionAttributes({
308 TenantName: name,
309 SuppressedReasons: news.suppression.reasons,
310 SuppressionScope: news.suppression.scope,
311 });
312 }
313
314 // 3b. SYNC TAGS — diff against OBSERVED cloud tags so adoption
315 // converges.
316 const observedTags = toTagRecord(observed.Tags);
317 const { upsert, removed } = diffTags(observedTags, desiredTags);
318 if (upsert.length > 0) {
319 yield* sesv2.tagResource({ ResourceArn: tenantArn, Tags: upsert });
320 }
321 if (removed.length > 0) {
322 yield* sesv2.untagResource({
323 ResourceArn: tenantArn,
324 TagKeys: removed,
325 });
326 }
327
328 yield* session.note(tenantArn);
329 return {
330 tenantName: name,
331 tenantId: observed.TenantId,
332 tenantArn,
333 };
334 }),
335
336 delete: Effect.fn(function* ({ output }) {
337 // deleteTenant removes the tenant and its resource associations (not
338 // the resources themselves), and is idempotent for a missing tenant.
339 yield* sesv2
340 .deleteTenant({ TenantName: output.tenantName })
341 .pipe(Effect.catchTag("NotFoundException", () => Effect.void));
342 }),
343 });
344 }),
345 );