| 1 | import * as sesv2 from "@distilled.cloud/aws/sesv2"; |
| 2 | import * as Effect from "effect/Effect"; |
| 3 | import * as Schedule from "effect/Schedule"; |
| 4 | import * as Stream from "effect/Stream"; |
| 5 | import { Unowned } from "../../AdoptPolicy.ts"; |
| 6 | import { isResolved } from "../../Diff.ts"; |
| 7 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 8 | import * as Provider from "../../Provider.ts"; |
| 9 | import { Resource } from "../../Resource.ts"; |
| 10 | import { |
| 11 | createInternalTags, |
| 12 | createTagsList, |
| 13 | diffTags, |
| 14 | hasAlchemyTags, |
| 15 | } from "../../Tags.ts"; |
| 16 | import type { Providers } from "../Providers.ts"; |
| 17 | import 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 | */ |
| 24 | export type SuppressionListScope = sesv2.SuppressionListScope; |
| 25 | |
| 26 | export 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 | |
| 39 | export 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 | |
| 63 | export 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 | */ |
| 157 | export const Tenant = Resource<Tenant>("AWS.SES.Tenant"); |
| 158 | |
| 159 | const toTagRecord = ( |
| 160 | tags: ReadonlyArray<{ Key: string; Value: string }> | undefined, |
| 161 | ): Record<string, string> => |
| 162 | Object.fromEntries((tags ?? []).map((tag) => [tag.Key, tag.Value])); |
| 163 | |
| 164 | const 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 | |
| 174 | export 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 | ); |