1import * as AWS from "@/AWS";
2import { ConfigurationSet, ConfigurationSetEventDestination } from "@/AWS/SES";
3import { Topic } from "@/AWS/SNS";
4import * as Test from "@/Test/Alchemy";
5import * as sesv2 from "@distilled.cloud/aws/sesv2";
6import { expect } from "alchemy-test";
7import * as Data from "effect/Data";
8import * as Effect from "effect/Effect";
9import * as Schedule from "effect/Schedule";
10
11const { test } = Test.make({ providers: AWS.providers() });
12
13// A verified subdomain with a valid certificate, used to host open/click
14// tracking links. SES rejects a redirect domain the account does not own.
15const TRACKING_REDIRECT_DOMAIN = process.env.AWS_TEST_SES_REDIRECT_DOMAIN;
16
17class ConfigurationSetStillExists extends Data.TaggedError(
18 "ConfigurationSetStillExists",
19)<{ readonly name: string }> {}
20
21const assertConfigurationSetDeleted = (name: string) =>
22 sesv2.getConfigurationSet({ ConfigurationSetName: name }).pipe(
23 Effect.flatMap(() =>
24 Effect.fail(new ConfigurationSetStillExists({ name })),
25 ),
26 Effect.catchTag("NotFoundException", () => Effect.void),
27 Effect.retry({
28 while: (e) => e._tag === "ConfigurationSetStillExists",
29 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(8)]),
30 }),
31 );
32
33test.provider(
34 "configuration set lifecycle: options, tags, no-op convergence",
35 (stack) =>
36 Effect.gen(function* () {
37 yield* stack.destroy();
38
39 const configSet = yield* stack.deploy(
40 Effect.gen(function* () {
41 return yield* ConfigurationSet("LifecycleConfigSet", {
42 reputationMetricsEnabled: true,
43 tlsPolicy: "REQUIRE",
44 maxDelivery: "1 hour",
45 suppressedReasons: ["BOUNCE"],
46 tags: { Environment: "test" },
47 });
48 }),
49 );
50
51 expect(configSet.configurationSetName).toBeDefined();
52 expect(configSet.configurationSetArn).toContain(":configuration-set/");
53
54 // out-of-band verification via distilled
55 const observed = yield* sesv2.getConfigurationSet({
56 ConfigurationSetName: configSet.configurationSetName,
57 });
58 expect(observed.ReputationOptions?.ReputationMetricsEnabled).toBe(true);
59 expect(observed.DeliveryOptions?.TlsPolicy).toBe("REQUIRE");
60 // Duration.Input prop converted to whole wire seconds
61 expect(observed.DeliveryOptions?.MaxDeliverySeconds).toBe(3600);
62 expect(observed.SuppressionOptions?.SuppressedReasons).toEqual([
63 "BOUNCE",
64 ]);
65 const tags = Object.fromEntries(
66 (observed.Tags ?? []).map((t) => [t.Key, t.Value]),
67 );
68 expect(tags.Environment).toBe("test");
69 expect(tags["alchemy::id"]).toBe("LifecycleConfigSet");
70
71 // sync options in place: disable sending, relax TLS, widen suppression
72 yield* stack.deploy(
73 Effect.gen(function* () {
74 return yield* ConfigurationSet("LifecycleConfigSet", {
75 sendingEnabled: false,
76 reputationMetricsEnabled: false,
77 tlsPolicy: "OPTIONAL",
78 maxDelivery: "30 minutes",
79 suppressedReasons: ["BOUNCE", "COMPLAINT"],
80 tags: { Environment: "test", Extra: "1" },
81 });
82 }),
83 );
84 const updated = yield* sesv2.getConfigurationSet({
85 ConfigurationSetName: configSet.configurationSetName,
86 });
87 expect(updated.SendingOptions?.SendingEnabled).toBe(false);
88 expect(updated.ReputationOptions?.ReputationMetricsEnabled).toBe(false);
89 expect(updated.DeliveryOptions?.TlsPolicy ?? "OPTIONAL").toBe("OPTIONAL");
90 expect(updated.DeliveryOptions?.MaxDeliverySeconds).toBe(1800);
91 expect(
92 [...(updated.SuppressionOptions?.SuppressedReasons ?? [])].sort(),
93 ).toEqual(["BOUNCE", "COMPLAINT"]);
94 const updatedTags = Object.fromEntries(
95 (updated.Tags ?? []).map((t) => [t.Key, t.Value]),
96 );
97 expect(updatedTags.Extra).toBe("1");
98
99 yield* stack.destroy();
100 yield* assertConfigurationSetDeleted(configSet.configurationSetName);
101 }),
102 { tags: ["provider:aws", "provider:aws:ses", "live"], timeout: 120_000 },
103);
104
105test.provider(
106 "custom name replaces on rename",
107 (stack) =>
108 Effect.gen(function* () {
109 yield* stack.destroy();
110
111 const first = yield* stack.deploy(
112 Effect.gen(function* () {
113 return yield* ConfigurationSet("NamedConfigSet", {
114 configurationSetName: "alchemy-test-ses-config-a",
115 });
116 }),
117 );
118 expect(first.configurationSetName).toBe("alchemy-test-ses-config-a");
119
120 const second = yield* stack.deploy(
121 Effect.gen(function* () {
122 return yield* ConfigurationSet("NamedConfigSet", {
123 configurationSetName: "alchemy-test-ses-config-b",
124 });
125 }),
126 );
127 expect(second.configurationSetName).toBe("alchemy-test-ses-config-b");
128 yield* assertConfigurationSetDeleted("alchemy-test-ses-config-a");
129
130 yield* stack.destroy();
131 yield* assertConfigurationSetDeleted("alchemy-test-ses-config-b");
132 }),
133 { tags: ["provider:aws", "provider:aws:ses", "live"], timeout: 120_000 },
134);
135
136test.provider(
137 "event destination lifecycle: SNS destination, update, delete",
138 (stack) =>
139 Effect.gen(function* () {
140 yield* stack.destroy();
141
142 const { topic, configSet, destination } = yield* stack.deploy(
143 Effect.gen(function* () {
144 const topic = yield* Topic("SesEventsTopic", {});
145 const configSet = yield* ConfigurationSet("EventsConfigSet", {});
146 const destination = yield* ConfigurationSetEventDestination(
147 "SnsDestination",
148 {
149 configurationSetName: configSet.configurationSetName,
150 matchingEventTypes: ["SEND", "DELIVERY"],
151 snsDestination: { topicArn: topic.topicArn },
152 },
153 );
154 return { topic, configSet, destination };
155 }),
156 );
157
158 expect(destination.eventDestinationName).toBeDefined();
159
160 // out-of-band verification via distilled
161 const observed = yield* sesv2.getConfigurationSetEventDestinations({
162 ConfigurationSetName: configSet.configurationSetName,
163 });
164 const found = (observed.EventDestinations ?? []).find(
165 (d) => d.Name === destination.eventDestinationName,
166 );
167 expect(found).toBeDefined();
168 expect(found!.Enabled).toBe(true);
169 expect([...found!.MatchingEventTypes].sort()).toEqual([
170 "DELIVERY",
171 "SEND",
172 ]);
173 expect(found!.SnsDestination?.TopicArn).toBe(topic.topicArn);
174
175 // update event types + disable in place
176 yield* stack.deploy(
177 Effect.gen(function* () {
178 const topic = yield* Topic("SesEventsTopic", {});
179 const configSet = yield* ConfigurationSet("EventsConfigSet", {});
180 const destination = yield* ConfigurationSetEventDestination(
181 "SnsDestination",
182 {
183 configurationSetName: configSet.configurationSetName,
184 enabled: false,
185 matchingEventTypes: ["SEND", "DELIVERY", "BOUNCE", "COMPLAINT"],
186 snsDestination: { topicArn: topic.topicArn },
187 },
188 );
189 return { configSet, destination };
190 }),
191 );
192 const updated = yield* sesv2.getConfigurationSetEventDestinations({
193 ConfigurationSetName: configSet.configurationSetName,
194 });
195 const updatedFound = (updated.EventDestinations ?? []).find(
196 (d) => d.Name === destination.eventDestinationName,
197 );
198 expect(updatedFound!.Enabled).toBe(false);
199 expect(updatedFound!.MatchingEventTypes).toHaveLength(4);
200
201 yield* stack.destroy();
202 yield* assertConfigurationSetDeleted(configSet.configurationSetName);
203 }),
204 {
205 tags: ["provider:aws", "provider:aws:ses", "provider:aws:sns", "live"],
206 timeout: 120_000,
207 },
208);
209
210// Tracking options and VDM options are the two aspects added alongside the
211// SESv2 coverage work; both are synced in place, never replaced.
212test.provider(
213 "syncs VDM options in place",
214 (stack) =>
215 Effect.gen(function* () {
216 yield* stack.destroy();
217
218 const configSet = yield* stack.deploy(
219 Effect.gen(function* () {
220 return yield* ConfigurationSet("VdmConfigSet", {
221 vdm: {
222 dashboardEngagementMetrics: "ENABLED",
223 guardianOptimizedSharedDelivery: "ENABLED",
224 },
225 });
226 }),
227 );
228
229 // out-of-band verification via distilled
230 const created = yield* sesv2.getConfigurationSet({
231 ConfigurationSetName: configSet.configurationSetName,
232 });
233 expect(created.VdmOptions?.DashboardOptions?.EngagementMetrics).toBe(
234 "ENABLED",
235 );
236 expect(created.VdmOptions?.GuardianOptions?.OptimizedSharedDelivery).toBe(
237 "ENABLED",
238 );
239
240 // flip both off in place — no replacement
241 yield* stack.deploy(
242 Effect.gen(function* () {
243 return yield* ConfigurationSet("VdmConfigSet", {
244 vdm: {
245 dashboardEngagementMetrics: "DISABLED",
246 guardianOptimizedSharedDelivery: "DISABLED",
247 },
248 });
249 }),
250 );
251 const updated = yield* sesv2.getConfigurationSet({
252 ConfigurationSetName: configSet.configurationSetName,
253 });
254 expect(updated.VdmOptions?.DashboardOptions?.EngagementMetrics).toBe(
255 "DISABLED",
256 );
257 expect(updated.VdmOptions?.GuardianOptions?.OptimizedSharedDelivery).toBe(
258 "DISABLED",
259 );
260
261 yield* stack.destroy();
262 yield* assertConfigurationSetDeleted(configSet.configurationSetName);
263 }),
264 { tags: ["provider:aws", "provider:aws:ses", "live"], timeout: 120_000 },
265);
266
267// Open/click tracking needs a verified subdomain with a valid certificate,
268// which a bare account has none of — SES rejects an unowned redirect domain.
269test.provider.skipIf(!TRACKING_REDIRECT_DOMAIN)(
270 "syncs custom tracking options (AWS_TEST_SES_REDIRECT_DOMAIN)",
271 (stack) =>
272 Effect.gen(function* () {
273 yield* stack.destroy();
274
275 const configSet = yield* stack.deploy(
276 Effect.gen(function* () {
277 return yield* ConfigurationSet("TrackingConfigSet", {
278 tracking: {
279 customRedirectDomain: TRACKING_REDIRECT_DOMAIN!,
280 httpsPolicy: "REQUIRE",
281 },
282 });
283 }),
284 );
285
286 const created = yield* sesv2.getConfigurationSet({
287 ConfigurationSetName: configSet.configurationSetName,
288 });
289 expect(created.TrackingOptions?.CustomRedirectDomain).toBe(
290 TRACKING_REDIRECT_DOMAIN,
291 );
292 expect(created.TrackingOptions?.HttpsPolicy).toBe("REQUIRE");
293
294 // change only the HTTPS policy — applied in place
295 yield* stack.deploy(
296 Effect.gen(function* () {
297 return yield* ConfigurationSet("TrackingConfigSet", {
298 tracking: {
299 customRedirectDomain: TRACKING_REDIRECT_DOMAIN!,
300 httpsPolicy: "OPTIONAL",
301 },
302 });
303 }),
304 );
305 const updated = yield* sesv2.getConfigurationSet({
306 ConfigurationSetName: configSet.configurationSetName,
307 });
308 expect(updated.TrackingOptions?.HttpsPolicy).toBe("OPTIONAL");
309
310 yield* stack.destroy();
311 yield* assertConfigurationSetDeleted(configSet.configurationSetName);
312 }),
313 { tags: ["provider:aws", "provider:aws:ses", "live"], timeout: 120_000 },
314);
315
316// putConfigurationSetVdmOptions replaces VdmOptions wholesale, so a caller
317// managing only one member would wipe the other. The provider backfills the
318// unmanaged member from observed state; this pins that behavior.
319test.provider(
320 "managing one VDM member preserves the other",
321 (stack) =>
322 Effect.gen(function* () {
323 yield* stack.destroy();
324
325 const configSet = yield* stack.deploy(
326 Effect.gen(function* () {
327 return yield* ConfigurationSet("VdmBackfill", {
328 vdm: {
329 dashboardEngagementMetrics: "ENABLED",
330 guardianOptimizedSharedDelivery: "ENABLED",
331 },
332 });
333 }),
334 );
335
336 // Re-deploy managing ONLY the dashboard member.
337 yield* stack.deploy(
338 Effect.gen(function* () {
339 return yield* ConfigurationSet("VdmBackfill", {
340 vdm: { dashboardEngagementMetrics: "DISABLED" },
341 });
342 }),
343 );
344
345 const observed = yield* sesv2.getConfigurationSet({
346 ConfigurationSetName: configSet.configurationSetName,
347 });
348 expect(observed.VdmOptions?.DashboardOptions?.EngagementMetrics).toBe(
349 "DISABLED",
350 );
351 // Guardian was never mentioned in the second deploy — it must survive.
352 expect(
353 observed.VdmOptions?.GuardianOptions?.OptimizedSharedDelivery,
354 ).toBe("ENABLED");
355
356 yield* stack.destroy();
357 yield* assertConfigurationSetDeleted(configSet.configurationSetName);
358 }),
359 { tags: ["provider:aws", "provider:aws:ses", "live"], timeout: 120_000 },
360);