1import * as sesv2 from "@distilled.cloud/aws/sesv2";
2import type * as Duration from "effect/Duration";
3import * as Effect from "effect/Effect";
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 { toWireSeconds } from "../../Util/Duration.ts";
17import { AWSEnvironment } from "../Environment.ts";
18import type { Providers } from "../Providers.ts";
19
20/**
21 * Whether SES requires (`REQUIRE`) or merely prefers (`OPTIONAL`) a TLS
22 * connection when delivering email through this configuration set.
23 */
24export type TlsPolicy = "REQUIRE" | "OPTIONAL";
25
26/**
27 * The reasons for which SES automatically adds recipients to the account
28 * suppression list when sending through this configuration set.
29 */
30export type SuppressionListReason = "BOUNCE" | "COMPLAINT";
31
32export interface ConfigurationSetTrackingSettings {
33 /**
34 * A custom domain — a verified subdomain you own, with a valid certificate —
35 * that SES uses to host open- and click-tracking links for email sent
36 * through this configuration set.
37 *
38 * Required: SES has no way to set an HTTPS policy for the default tracking
39 * domain, so `httpsPolicy` is only meaningful alongside a custom domain.
40 */
41 customRedirectDomain: string;
42 /**
43 * Whether tracking links use HTTPS (`REQUIRE`), plain HTTP (`OPTIONAL`), or
44 * HTTPS only for the open pixel (`REQUIRE_OPEN_ONLY`).
45 */
46 httpsPolicy?: sesv2.HttpsPolicy;
47}
48
49export interface ConfigurationSetVdmSettings {
50 /**
51 * Whether SES collects engagement (open/click) metrics for the Virtual
52 * Deliverability Manager dashboard for email sent through this configuration
53 * set.
54 */
55 dashboardEngagementMetrics?: sesv2.FeatureStatus;
56 /**
57 * Whether SES applies Guardian optimized shared delivery to email sent
58 * through this configuration set.
59 */
60 guardianOptimizedSharedDelivery?: sesv2.FeatureStatus;
61}
62
63export interface ConfigurationSetProps {
64 /**
65 * The name of the configuration set. May contain letters, numbers, hyphens
66 * and underscores, up to 64 characters. If omitted, a deterministic
67 * physical name is generated from the app, stage, and logical ID.
68 * Changing the name replaces the configuration set.
69 */
70 configurationSetName?: string;
71 /**
72 * Whether email sending through this configuration set is enabled.
73 * @default true
74 */
75 sendingEnabled?: boolean;
76 /**
77 * Whether SES publishes reputation metrics (bounce and complaint rates)
78 * for this configuration set to CloudWatch.
79 * @default false
80 */
81 reputationMetricsEnabled?: boolean;
82 /**
83 * Whether SES requires a TLS connection when delivering email through
84 * this configuration set. Messages are dropped when `REQUIRE` is set and
85 * TLS cannot be established.
86 * @default "OPTIONAL"
87 */
88 tlsPolicy?: TlsPolicy;
89 /**
90 * The maximum amount of time (5 minutes to 14 hours) that SES will attempt
91 * delivery of email through this configuration set. Accepts any
92 * `Duration.Input` (e.g. `"1 hour"`, `Duration.hours(1)`; a bare number
93 * is milliseconds); the wire unit is whole seconds.
94 */
95 maxDelivery?: Duration.Input;
96 /**
97 * Which events cause SES to add a recipient to the account suppression
98 * list when sending through this configuration set. Overrides the
99 * account-level setting; leave undefined to inherit it.
100 */
101 suppressedReasons?: SuppressionListReason[];
102 /**
103 * Open- and click-tracking configuration. Leave undefined to keep whatever
104 * SES currently has.
105 *
106 * Members cannot be unset once set: SES exposes no API to remove a custom
107 * redirect domain, so dropping this prop keeps the current state rather
108 * than restoring the default SES tracking domain.
109 */
110 tracking?: ConfigurationSetTrackingSettings;
111 /**
112 * Virtual Deliverability Manager configuration for this configuration set.
113 * Requires account-level VDM to be enabled — see `SES.AccountSettings`.
114 *
115 * Members cannot be unset once set: dropping this prop keeps the current
116 * state rather than clearing it. A member you do omit while managing its
117 * sibling is preserved (SES replaces VdmOptions wholesale, so the
118 * unmanaged member is backfilled from observed state).
119 */
120 vdm?: ConfigurationSetVdmSettings;
121 /**
122 * Tags to apply to the configuration set. Merged with internal Alchemy
123 * tags.
124 */
125 tags?: Record<string, string>;
126}
127
128export interface ConfigurationSet extends Resource<
129 "AWS.SES.ConfigurationSet",
130 ConfigurationSetProps,
131 {
132 configurationSetName: string;
133 configurationSetArn: string;
134 },
135 never,
136 Providers
137> {}
138
139/**
140 * An Amazon SES v2 configuration set — a named group of sending options
141 * (TLS policy, reputation metrics, suppression overrides) that you apply to
142 * outbound email, either per-message or as an identity's default.
143 *
144 * Attach event destinations with `SES.ConfigurationSetEventDestination` to
145 * stream send/delivery/bounce/complaint events to SNS, EventBridge, or
146 * CloudWatch.
147 * ### Creating Configuration Sets
148 * **Example:** Basic Configuration Set
149 * ```typescript
150 * import * as SES from "alchemy/AWS/SES";
151 *
152 * const configSet = yield* SES.ConfigurationSet("Default", {});
153 * ```
154 *
155 * **Example:** Require TLS and Publish Reputation Metrics
156 * ```typescript
157 * const configSet = yield* SES.ConfigurationSet("Strict", {
158 * tlsPolicy: "REQUIRE",
159 * reputationMetricsEnabled: true,
160 * });
161 * ```
162 *
163 * **Example:** Suppress Bounces and Complaints
164 * ```typescript
165 * const configSet = yield* SES.ConfigurationSet("Suppressing", {
166 * suppressedReasons: ["BOUNCE", "COMPLAINT"],
167 * });
168 * ```
169 *
170 * ### Open and Click Tracking
171 * **Example:** Host Tracking Links on Your Own Domain
172 * ```typescript
173 * // The redirect domain must be a verified subdomain you own with a valid
174 * // certificate. Omit `tracking` entirely to keep SES's current setting.
175 * const configSet = yield* SES.ConfigurationSet("Tracked", {
176 * tracking: {
177 * customRedirectDomain: "links.example.com",
178 * httpsPolicy: "REQUIRE",
179 * },
180 * });
181 * ```
182 *
183 * ### Virtual Deliverability Manager
184 * **Example:** Collect Engagement Metrics for This Configuration Set
185 * ```typescript
186 * // Requires account-level VDM — see SES.AccountSettings.
187 * const configSet = yield* SES.ConfigurationSet("Measured", {
188 * vdm: {
189 * dashboardEngagementMetrics: "ENABLED",
190 * guardianOptimizedSharedDelivery: "ENABLED",
191 * },
192 * });
193 * ```
194 *
195 * ### Event Destinations
196 * **Example:** Stream Events to SNS
197 * ```typescript
198 * const topic = yield* SNS.Topic("EmailEvents", {});
199 * const destination = yield* SES.ConfigurationSetEventDestination("ToSns", {
200 * configurationSetName: configSet.configurationSetName,
201 * matchingEventTypes: ["SEND", "DELIVERY", "BOUNCE", "COMPLAINT"],
202 * snsDestination: { topicArn: topic.topicArn },
203 * });
204 * ```
205 *
206 * @resource
207 */
208export const ConfigurationSet = Resource<ConfigurationSet>(
209 "AWS.SES.ConfigurationSet",
210);
211
212const toTagRecord = (
213 tags: ReadonlyArray<{ Key: string; Value: string }> | undefined,
214): Record<string, string> =>
215 Object.fromEntries((tags ?? []).map((tag) => [tag.Key, tag.Value]));
216
217const configurationSetArnOf = (
218 region: string,
219 accountId: string,
220 name: string,
221) => `arn:aws:ses:${region}:${accountId}:configuration-set/${name}`;
222
223const sameReasons = (
224 a: ReadonlyArray<string> | undefined,
225 b: ReadonlyArray<string>,
226) => {
227 const left = [...(a ?? [])].sort();
228 const right = [...b].sort();
229 return left.length === right.length && left.every((v, i) => v === right[i]);
230};
231
232export const ConfigurationSetProvider = () =>
233 Provider.effect(
234 ConfigurationSet,
235 Effect.gen(function* () {
236 const createName = Effect.fn(function* (
237 id: string,
238 props: Pick<ConfigurationSetProps, "configurationSetName">,
239 ) {
240 return (
241 props.configurationSetName ??
242 (yield* createPhysicalName({ id, maxLength: 64 }))
243 );
244 });
245
246 const getConfigurationSet = Effect.fn(function* (name: string) {
247 return yield* sesv2
248 .getConfigurationSet({ ConfigurationSetName: name })
249 .pipe(
250 Effect.catchTag("NotFoundException", () =>
251 Effect.succeed(undefined),
252 ),
253 );
254 });
255
256 return ConfigurationSet.Provider.of({
257 stables: ["configurationSetName", "configurationSetArn"],
258
259 list: () =>
260 Effect.gen(function* () {
261 const { accountId, region } = yield* AWSEnvironment.current;
262 const pages = yield* sesv2.listConfigurationSets
263 .pages({})
264 .pipe(Stream.runCollect);
265 return Array.from(pages)
266 .flatMap((page) => page.ConfigurationSets ?? [])
267 .map((name) => ({
268 configurationSetName: name,
269 configurationSetArn: configurationSetArnOf(
270 region,
271 accountId,
272 name,
273 ),
274 }));
275 }),
276
277 read: Effect.fn(function* ({ id, olds, output }) {
278 const { accountId, region } = yield* AWSEnvironment.current;
279 const name =
280 output?.configurationSetName ?? (yield* createName(id, olds ?? {}));
281 const found = yield* getConfigurationSet(name);
282 if (!found) return undefined;
283 const attrs = {
284 configurationSetName: name,
285 configurationSetArn: configurationSetArnOf(region, accountId, name),
286 };
287 const tags = toTagRecord(found.Tags);
288 return (yield* hasAlchemyTags(id, tags)) ? attrs : Unowned(attrs);
289 }),
290
291 diff: Effect.fn(function* ({ id, news, olds }) {
292 if (!isResolved(news)) return undefined;
293 const oldName = yield* createName(id, olds ?? {});
294 const newName = yield* createName(id, news ?? {});
295 if (oldName !== newName) {
296 return { action: "replace" } as const;
297 }
298 }),
299
300 reconcile: Effect.fn(function* ({ id, news, output, session }) {
301 const { accountId, region } = yield* AWSEnvironment.current;
302 const name =
303 output?.configurationSetName ?? (yield* createName(id, news));
304 const configurationSetArn = configurationSetArnOf(
305 region,
306 accountId,
307 name,
308 );
309 const internalTags = yield* createInternalTags(id);
310 const desiredTags = { ...news.tags, ...internalTags };
311 const desiredMaxDelivery = toWireSeconds(news.maxDelivery);
312
313 // 1. OBSERVE — cloud state is authoritative.
314 let observed = yield* getConfigurationSet(name);
315
316 // 2. ENSURE — create with the full desired option set in one call;
317 // AlreadyExists is a race, not a failure.
318 if (observed === undefined) {
319 yield* sesv2
320 .createConfigurationSet({
321 ConfigurationSetName: name,
322 SendingOptions:
323 news.sendingEnabled !== undefined
324 ? { SendingEnabled: news.sendingEnabled }
325 : undefined,
326 ReputationOptions:
327 news.reputationMetricsEnabled !== undefined
328 ? {
329 ReputationMetricsEnabled: news.reputationMetricsEnabled,
330 }
331 : undefined,
332 DeliveryOptions:
333 news.tlsPolicy !== undefined ||
334 desiredMaxDelivery !== undefined
335 ? {
336 TlsPolicy: news.tlsPolicy,
337 MaxDeliverySeconds: desiredMaxDelivery,
338 }
339 : undefined,
340 SuppressionOptions:
341 news.suppressedReasons !== undefined
342 ? { SuppressedReasons: news.suppressedReasons }
343 : undefined,
344 Tags: createTagsList(desiredTags),
345 })
346 .pipe(
347 Effect.catchTag("AlreadyExistsException", () =>
348 Effect.succeed({}),
349 ),
350 );
351 observed = yield* sesv2.getConfigurationSet({
352 ConfigurationSetName: name,
353 });
354 }
355
356 // 3. SYNC — per mutable aspect: diff observed against desired and
357 // apply only the delta.
358 const desiredSending = news.sendingEnabled ?? true;
359 if (
360 (observed.SendingOptions?.SendingEnabled ?? true) !== desiredSending
361 ) {
362 yield* sesv2.putConfigurationSetSendingOptions({
363 ConfigurationSetName: name,
364 SendingEnabled: desiredSending,
365 });
366 }
367
368 const desiredReputation = news.reputationMetricsEnabled ?? false;
369 if (
370 (observed.ReputationOptions?.ReputationMetricsEnabled ?? false) !==
371 desiredReputation
372 ) {
373 yield* sesv2.putConfigurationSetReputationOptions({
374 ConfigurationSetName: name,
375 ReputationMetricsEnabled: desiredReputation,
376 });
377 }
378
379 const desiredTls = news.tlsPolicy ?? "OPTIONAL";
380 const observedTls = observed.DeliveryOptions?.TlsPolicy ?? "OPTIONAL";
381 const observedMaxDelivery =
382 observed.DeliveryOptions?.MaxDeliverySeconds;
383 if (
384 observedTls !== desiredTls ||
385 (desiredMaxDelivery !== undefined &&
386 observedMaxDelivery !== desiredMaxDelivery)
387 ) {
388 yield* sesv2.putConfigurationSetDeliveryOptions({
389 ConfigurationSetName: name,
390 TlsPolicy: desiredTls,
391 MaxDeliverySeconds: desiredMaxDelivery ?? observedMaxDelivery,
392 // preserve any dedicated sending pool configured out-of-band
393 SendingPoolName: observed.DeliveryOptions?.SendingPoolName,
394 });
395 }
396
397 // Suppression is only synced when explicitly configured so an
398 // unset prop keeps inheriting the account-level defaults.
399 if (
400 news.suppressedReasons !== undefined &&
401 !sameReasons(
402 observed.SuppressionOptions?.SuppressedReasons,
403 news.suppressedReasons,
404 )
405 ) {
406 yield* sesv2.putConfigurationSetSuppressionOptions({
407 ConfigurationSetName: name,
408 SuppressedReasons: news.suppressedReasons,
409 });
410 }
411
412 // Tracking is only synced when the caller manages it; leaving the
413 // prop undefined keeps whatever SES currently has. `httpsPolicy`
414 // cannot drift on its own — the type requires a custom domain
415 // alongside it, because SES has no way to set an HTTPS policy for
416 // the default tracking domain.
417 if (
418 news.tracking !== undefined &&
419 (observed.TrackingOptions?.CustomRedirectDomain !==
420 news.tracking.customRedirectDomain ||
421 (news.tracking.httpsPolicy !== undefined &&
422 observed.TrackingOptions?.HttpsPolicy !==
423 news.tracking.httpsPolicy))
424 ) {
425 yield* sesv2.putConfigurationSetTrackingOptions({
426 ConfigurationSetName: name,
427 CustomRedirectDomain: news.tracking.customRedirectDomain,
428 HttpsPolicy: news.tracking.httpsPolicy,
429 });
430 }
431
432 // VDM is only synced when explicitly configured; it requires
433 // account-level VDM to be enabled.
434 //
435 // putConfigurationSetVdmOptions REPLACES VdmOptions wholesale: an
436 // omitted DashboardOptions/GuardianOptions is dropped, not left
437 // alone (verified against the live API). So a caller who manages
438 // only one of the two would silently wipe the other on every
439 // deploy. Backfill the unmanaged member from observed state.
440 const desiredDashboard =
441 news.vdm?.dashboardEngagementMetrics ??
442 observed.VdmOptions?.DashboardOptions?.EngagementMetrics;
443 const desiredGuardian =
444 news.vdm?.guardianOptimizedSharedDelivery ??
445 observed.VdmOptions?.GuardianOptions?.OptimizedSharedDelivery;
446 if (
447 news.vdm !== undefined &&
448 (observed.VdmOptions?.DashboardOptions?.EngagementMetrics !==
449 desiredDashboard ||
450 observed.VdmOptions?.GuardianOptions?.OptimizedSharedDelivery !==
451 desiredGuardian)
452 ) {
453 yield* sesv2.putConfigurationSetVdmOptions({
454 ConfigurationSetName: name,
455 VdmOptions: {
456 DashboardOptions:
457 desiredDashboard !== undefined
458 ? { EngagementMetrics: desiredDashboard }
459 : undefined,
460 GuardianOptions:
461 desiredGuardian !== undefined
462 ? { OptimizedSharedDelivery: desiredGuardian }
463 : undefined,
464 },
465 });
466 }
467
468 // 3b. SYNC TAGS — diff against OBSERVED cloud tags.
469 const observedTags = toTagRecord(observed.Tags);
470 const { upsert, removed } = diffTags(observedTags, desiredTags);
471 if (upsert.length > 0) {
472 yield* sesv2.tagResource({
473 ResourceArn: configurationSetArn,
474 Tags: upsert,
475 });
476 }
477 if (removed.length > 0) {
478 yield* sesv2.untagResource({
479 ResourceArn: configurationSetArn,
480 TagKeys: removed,
481 });
482 }
483
484 yield* session.note(configurationSetArn);
485 return { configurationSetName: name, configurationSetArn };
486 }),
487
488 delete: Effect.fn(function* ({ output }) {
489 yield* sesv2
490 .deleteConfigurationSet({
491 ConfigurationSetName: output.configurationSetName,
492 })
493 .pipe(Effect.catchTag("NotFoundException", () => Effect.void));
494 }),
495 });
496 }),
497 );