1import type * as sesv2Types from "@distilled.cloud/aws/sesv2";
2import * as sesv2 from "@distilled.cloud/aws/sesv2";
3import * as Effect from "effect/Effect";
4import * as Stream from "effect/Stream";
5import { isResolved } from "../../Diff.ts";
6import { createPhysicalName } from "../../PhysicalName.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import type { Providers } from "../Providers.ts";
10
11/**
12 * The email-sending events an event destination can match, e.g. `SEND`,
13 * `DELIVERY`, `BOUNCE`, `COMPLAINT`, `OPEN`, `CLICK`.
14 */
15export type EventType = sesv2Types.EventType;
16
17export interface CloudWatchDimensionConfiguration {
18 /**
19 * The name of the CloudWatch dimension, e.g. `ses:configuration-set`.
20 */
21 dimensionName: string;
22 /**
23 * Where SES reads the dimension value from: a message tag
24 * (`MESSAGE_TAG`), an email header (`EMAIL_HEADER`), or a link tag
25 * (`LINK_TAG`).
26 */
27 dimensionValueSource: sesv2Types.DimensionValueSource;
28 /**
29 * The value SES publishes when the message doesn't carry the tag/header.
30 */
31 defaultDimensionValue: string;
32}
33
34export interface ConfigurationSetEventDestinationProps {
35 /**
36 * The name of the configuration set that owns this event destination.
37 * Changing it replaces the destination.
38 */
39 configurationSetName: string;
40 /**
41 * The name of the event destination. If omitted, a deterministic physical
42 * name is generated from the app, stage, and logical ID. Changing the
43 * name replaces the destination.
44 */
45 eventDestinationName?: string;
46 /**
47 * Whether the event destination is active.
48 * @default true
49 */
50 enabled?: boolean;
51 /**
52 * The event types to publish to the destination, e.g.
53 * `["SEND", "DELIVERY", "BOUNCE", "COMPLAINT"]`.
54 */
55 matchingEventTypes: EventType[];
56 /**
57 * Publish events to an SNS topic.
58 */
59 snsDestination?: {
60 /** The ARN of the SNS topic to publish events to. */
61 topicArn: string;
62 };
63 /**
64 * Publish events to an EventBridge event bus (the default bus only).
65 */
66 eventBridgeDestination?: {
67 /** The ARN of the EventBridge event bus (must be the default bus). */
68 eventBusArn: string;
69 };
70 /**
71 * Publish event metrics to CloudWatch.
72 */
73 cloudWatchDestination?: {
74 /** How SES maps message tags/headers to CloudWatch dimensions. */
75 dimensionConfigurations: CloudWatchDimensionConfiguration[];
76 };
77}
78
79export interface ConfigurationSetEventDestination extends Resource<
80 "AWS.SES.ConfigurationSetEventDestination",
81 ConfigurationSetEventDestinationProps,
82 {
83 configurationSetName: string;
84 eventDestinationName: string;
85 },
86 never,
87 Providers
88> {}
89
90/**
91 * An event destination on an SES v2 configuration set — streams
92 * send/delivery/bounce/complaint (and open/click) events to SNS,
93 * EventBridge, or CloudWatch.
94 * ### Creating Event Destinations
95 * **Example:** Publish Bounce and Complaint Events to SNS
96 * ```typescript
97 * import * as SES from "alchemy/AWS/SES";
98 * import * as SNS from "alchemy/AWS/SNS";
99 *
100 * const topic = yield* SNS.Topic("EmailEvents", {});
101 * const configSet = yield* SES.ConfigurationSet("Default", {});
102 *
103 * const destination = yield* SES.ConfigurationSetEventDestination("ToSns", {
104 * configurationSetName: configSet.configurationSetName,
105 * matchingEventTypes: ["BOUNCE", "COMPLAINT"],
106 * snsDestination: { topicArn: topic.topicArn },
107 * });
108 * ```
109 *
110 * **Example:** Publish Metrics to CloudWatch
111 * ```typescript
112 * const metrics = yield* SES.ConfigurationSetEventDestination("Metrics", {
113 * configurationSetName: configSet.configurationSetName,
114 * matchingEventTypes: ["SEND", "DELIVERY"],
115 * cloudWatchDestination: {
116 * dimensionConfigurations: [
117 * {
118 * dimensionName: "campaign",
119 * dimensionValueSource: "MESSAGE_TAG",
120 * defaultDimensionValue: "none",
121 * },
122 * ],
123 * },
124 * });
125 * ```
126 *
127 * @resource
128 */
129export const ConfigurationSetEventDestination =
130 Resource<ConfigurationSetEventDestination>(
131 "AWS.SES.ConfigurationSetEventDestination",
132 );
133
134const toDefinition = (
135 props: ConfigurationSetEventDestinationProps,
136): sesv2Types.EventDestinationDefinition => ({
137 Enabled: props.enabled ?? true,
138 MatchingEventTypes: props.matchingEventTypes,
139 SnsDestination: props.snsDestination
140 ? { TopicArn: props.snsDestination.topicArn }
141 : undefined,
142 EventBridgeDestination: props.eventBridgeDestination
143 ? { EventBusArn: props.eventBridgeDestination.eventBusArn }
144 : undefined,
145 CloudWatchDestination: props.cloudWatchDestination
146 ? {
147 DimensionConfigurations:
148 props.cloudWatchDestination.dimensionConfigurations.map((d) => ({
149 DimensionName: d.dimensionName,
150 DimensionValueSource: d.dimensionValueSource,
151 DefaultDimensionValue: d.defaultDimensionValue,
152 })),
153 }
154 : undefined,
155});
156
157const isInSync = (
158 observed: sesv2Types.EventDestination,
159 desired: sesv2Types.EventDestinationDefinition,
160) => {
161 const sortedTypes = (types: ReadonlyArray<string> | undefined) =>
162 [...(types ?? [])].sort();
163 const observedTypes = sortedTypes(observed.MatchingEventTypes);
164 const desiredTypes = sortedTypes(desired.MatchingEventTypes);
165 return (
166 (observed.Enabled ?? false) === (desired.Enabled ?? true) &&
167 observedTypes.length === desiredTypes.length &&
168 observedTypes.every((t, i) => t === desiredTypes[i]) &&
169 observed.SnsDestination?.TopicArn === desired.SnsDestination?.TopicArn &&
170 observed.EventBridgeDestination?.EventBusArn ===
171 desired.EventBridgeDestination?.EventBusArn &&
172 JSON.stringify(
173 observed.CloudWatchDestination?.DimensionConfigurations ?? null,
174 ) ===
175 JSON.stringify(
176 desired.CloudWatchDestination?.DimensionConfigurations ?? null,
177 )
178 );
179};
180
181export const ConfigurationSetEventDestinationProvider = () =>
182 Provider.effect(
183 ConfigurationSetEventDestination,
184 Effect.gen(function* () {
185 const createName = Effect.fn(function* (
186 id: string,
187 props: Pick<
188 ConfigurationSetEventDestinationProps,
189 "eventDestinationName"
190 >,
191 ) {
192 return (
193 props.eventDestinationName ??
194 (yield* createPhysicalName({ id, maxLength: 64 }))
195 );
196 });
197
198 const findDestination = Effect.fn(function* (
199 configurationSetName: string,
200 eventDestinationName: string,
201 ) {
202 const destinations = yield* sesv2
203 .getConfigurationSetEventDestinations({
204 ConfigurationSetName: configurationSetName,
205 })
206 .pipe(
207 Effect.map((r) => r.EventDestinations ?? []),
208 // configuration set (or destination) missing → not found
209 Effect.catchTag("NotFoundException", () =>
210 Effect.succeed([] as sesv2Types.EventDestination[]),
211 ),
212 );
213 return destinations.find((d) => d.Name === eventDestinationName);
214 });
215
216 return ConfigurationSetEventDestination.Provider.of({
217 stables: ["configurationSetName", "eventDestinationName"],
218
219 list: () =>
220 Effect.gen(function* () {
221 const pages = yield* sesv2.listConfigurationSets
222 .pages({})
223 .pipe(Stream.runCollect);
224 const configSets = Array.from(pages).flatMap(
225 (page) => page.ConfigurationSets ?? [],
226 );
227 const nested = yield* Effect.forEach(
228 configSets,
229 (configurationSetName) =>
230 sesv2
231 .getConfigurationSetEventDestinations({
232 ConfigurationSetName: configurationSetName,
233 })
234 .pipe(
235 Effect.map((r) =>
236 (r.EventDestinations ?? []).map((d) => ({
237 configurationSetName,
238 eventDestinationName: d.Name,
239 })),
240 ),
241 Effect.catchTag("NotFoundException", () =>
242 Effect.succeed(
243 [] as {
244 configurationSetName: string;
245 eventDestinationName: string;
246 }[],
247 ),
248 ),
249 ),
250 { concurrency: 2 },
251 );
252 return nested.flat();
253 }),
254
255 read: Effect.fn(function* ({ id, olds, output }) {
256 const configurationSetName =
257 output?.configurationSetName ?? olds?.configurationSetName;
258 if (configurationSetName === undefined) return undefined;
259 const name =
260 output?.eventDestinationName ?? (yield* createName(id, olds ?? {}));
261 const found = yield* findDestination(configurationSetName, name);
262 if (!found) return undefined;
263 return { configurationSetName, eventDestinationName: name };
264 }),
265
266 diff: Effect.fn(function* ({ id, news, olds }) {
267 if (!isResolved(news)) return undefined;
268 const oldName = yield* createName(id, olds ?? {});
269 const newName = yield* createName(id, news ?? {});
270 if (
271 oldName !== newName ||
272 (olds?.configurationSetName !== undefined &&
273 news?.configurationSetName !== undefined &&
274 olds.configurationSetName !== news.configurationSetName)
275 ) {
276 return { action: "replace" } as const;
277 }
278 }),
279
280 reconcile: Effect.fn(function* ({ id, news, output, session }) {
281 const configurationSetName = news.configurationSetName;
282 const name =
283 output?.eventDestinationName ?? (yield* createName(id, news));
284 const desired = toDefinition(news);
285
286 // 1. OBSERVE — look the destination up on the owning set.
287 const observed = yield* findDestination(configurationSetName, name);
288
289 if (observed === undefined) {
290 // 2. ENSURE — create; AlreadyExists is a race → converge with
291 // an update instead.
292 yield* sesv2
293 .createConfigurationSetEventDestination({
294 ConfigurationSetName: configurationSetName,
295 EventDestinationName: name,
296 EventDestination: desired,
297 })
298 .pipe(
299 Effect.catchTag("AlreadyExistsException", () =>
300 sesv2.updateConfigurationSetEventDestination({
301 ConfigurationSetName: configurationSetName,
302 EventDestinationName: name,
303 EventDestination: desired,
304 }),
305 ),
306 );
307 } else if (!isInSync(observed, desired)) {
308 // 3. SYNC — apply the delta only when observed drifted.
309 yield* sesv2.updateConfigurationSetEventDestination({
310 ConfigurationSetName: configurationSetName,
311 EventDestinationName: name,
312 EventDestination: desired,
313 });
314 }
315
316 yield* session.note(`${configurationSetName}/${name}`);
317 return { configurationSetName, eventDestinationName: name };
318 }),
319
320 delete: Effect.fn(function* ({ output }) {
321 yield* sesv2
322 .deleteConfigurationSetEventDestination({
323 ConfigurationSetName: output.configurationSetName,
324 EventDestinationName: output.eventDestinationName,
325 })
326 .pipe(Effect.catchTag("NotFoundException", () => Effect.void));
327 }),
328 });
329 }),
330 );