1import * as AWS from "@/AWS";
2import {
3 AddonInstance,
4 AddonSubscription,
5 AddressList,
6 Archive,
7 IngressPoint,
8 Relay,
9 RuleSet,
10 TrafficPolicy,
11} from "@/AWS/MailManager";
12import * as Test from "@/Test/Alchemy";
13import * as mm from "@distilled.cloud/aws/mailmanager";
14import { expect } from "alchemy-test";
15import * as Effect from "effect/Effect";
16import * as Schedule from "effect/Schedule";
17
18const { test } = Test.make({ providers: AWS.providers() });
19
20// Ungated typed-error probes: prove the distilled error union carries the
21// not-found tag every MailManager provider's read/delete path depends on.
22// Runs in every CI pass at near-zero cost.
23test.provider(
24 "getRuleSet on a bogus id fails with ResourceNotFoundException",
25 () =>
26 Effect.gen(function* () {
27 const error = yield* Effect.flip(
28 // Well-formed (rs- + 26 alphanumerics) but nonexistent — a malformed
29 // id fails ValidationException before the existence check.
30 mm.getRuleSet({ RuleSetId: "rs-00000000000000000000000000" }),
31 );
32 expect(error._tag).toBe("ResourceNotFoundException");
33 }),
34 { tags: ["provider:aws", "provider:aws:mailmanager", "live"] },
35);
36
37// Mail Manager deletes are natively idempotent — deleting a nonexistent
38// (well-formed) id SUCCEEDS rather than failing ResourceNotFoundException,
39// which is why deleteRuleSet's error union carries no not-found tag and the
40// providers' delete paths need no catch. This probe pins that behavior.
41test.provider(
42 "deleteRuleSet on a nonexistent id succeeds (idempotent)",
43 () =>
44 Effect.gen(function* () {
45 yield* mm.deleteRuleSet({ RuleSetId: "rs-00000000000000000000000000" });
46 yield* mm.deleteTrafficPolicy({
47 TrafficPolicyId: "tp-00000000000000000000000000",
48 });
49 expect(true).toBe(true);
50 }),
51 { tags: ["provider:aws", "provider:aws:mailmanager", "live"] },
52);
53
54// Rule sets and traffic policies are free, provision instantly, and need no
55// verified domains — full lifecycle runs ungated. Relays are also free
56// config-only resources (creation does not validate connectivity).
57test.provider(
58 "lifecycle: rule set + traffic policy + relay",
59 (stack) =>
60 Effect.gen(function* () {
61 yield* stack.destroy();
62
63 // Create.
64 const deployed = yield* stack.deploy(
65 Effect.gen(function* () {
66 const ruleSet = yield* RuleSet("Inbound", {
67 rules: [{ Name: "DropAll", Actions: [{ Drop: {} }] }],
68 tags: { fixture: "mailmanager" },
69 });
70 const trafficPolicy = yield* TrafficPolicy("Edge", {
71 defaultAction: "ALLOW",
72 policyStatements: [
73 {
74 Action: "DENY",
75 Conditions: [
76 {
77 IpExpression: {
78 Evaluate: { Attribute: "SENDER_IP" },
79 Operator: "CIDR_MATCHES",
80 Values: ["192.0.2.0/24"],
81 },
82 },
83 ],
84 },
85 ],
86 maxMessageSizeBytes: 10_485_760,
87 tags: { fixture: "mailmanager" },
88 });
89 const relay = yield* Relay("Downstream", {
90 serverName: "smtp.example.com",
91 serverPort: 25,
92 authentication: { NoAuthentication: {} },
93 tags: { fixture: "mailmanager" },
94 });
95 return { ruleSet, trafficPolicy, relay };
96 }),
97 );
98
99 expect(deployed.ruleSet.ruleSetId).toBeDefined();
100 expect(deployed.ruleSet.ruleSetArn).toContain("mailmanager");
101 expect(deployed.trafficPolicy.trafficPolicyId).toBeDefined();
102 expect(deployed.trafficPolicy.trafficPolicyArn).toContain("mailmanager");
103 expect(deployed.relay.relayId).toBeDefined();
104 expect(deployed.relay.relayArn).toContain("mailmanager");
105
106 // Out-of-band verification via distilled.
107 const liveRuleSet = yield* mm.getRuleSet({
108 RuleSetId: deployed.ruleSet.ruleSetId,
109 });
110 expect(liveRuleSet.RuleSetName).toBe(deployed.ruleSet.ruleSetName);
111 expect(liveRuleSet.Rules).toHaveLength(1);
112 expect(liveRuleSet.Rules[0]?.Name).toBe("DropAll");
113
114 const livePolicy = yield* mm.getTrafficPolicy({
115 TrafficPolicyId: deployed.trafficPolicy.trafficPolicyId,
116 });
117 expect(livePolicy.DefaultAction).toBe("ALLOW");
118 expect(livePolicy.MaxMessageSizeBytes).toBe(10_485_760);
119 expect(livePolicy.PolicyStatements).toHaveLength(1);
120
121 const liveRelay = yield* mm.getRelay({
122 RelayId: deployed.relay.relayId,
123 });
124 expect(liveRelay.ServerName).toBe("smtp.example.com");
125 expect(liveRelay.ServerPort).toBe(25);
126
127 const ruleSetTags = yield* mm.listTagsForResource({
128 ResourceArn: deployed.ruleSet.ruleSetArn,
129 });
130 expect(
131 ruleSetTags.Tags?.some(
132 (t) => t.Key === "alchemy::id" && t.Value === "Inbound",
133 ),
134 ).toBe(true);
135 expect(
136 ruleSetTags.Tags?.some(
137 (t) => t.Key === "fixture" && t.Value === "mailmanager",
138 ),
139 ).toBe(true);
140
141 // Update — rules, statements, relay port, and tags all change in
142 // place; identities are stable.
143 const updated = yield* stack.deploy(
144 Effect.gen(function* () {
145 const ruleSet = yield* RuleSet("Inbound", {
146 rules: [
147 {
148 Name: "DropSecret",
149 Conditions: [
150 {
151 StringExpression: {
152 Evaluate: { Attribute: "SUBJECT" },
153 Operator: "CONTAINS",
154 Values: ["secret"],
155 },
156 },
157 ],
158 Actions: [{ Drop: {} }],
159 },
160 { Name: "DropRest", Actions: [{ Drop: {} }] },
161 ],
162 tags: { fixture: "mailmanager", env: "test" },
163 });
164 const trafficPolicy = yield* TrafficPolicy("Edge", {
165 defaultAction: "DENY",
166 policyStatements: [
167 {
168 Action: "ALLOW",
169 Conditions: [
170 {
171 IpExpression: {
172 Evaluate: { Attribute: "SENDER_IP" },
173 Operator: "CIDR_MATCHES",
174 Values: ["198.51.100.0/24"],
175 },
176 },
177 ],
178 },
179 ],
180 maxMessageSizeBytes: 5_242_880,
181 tags: { fixture: "mailmanager" },
182 });
183 const relay = yield* Relay("Downstream", {
184 serverName: "smtp.example.com",
185 serverPort: 587,
186 authentication: { NoAuthentication: {} },
187 tags: { fixture: "mailmanager" },
188 });
189 return { ruleSet, trafficPolicy, relay };
190 }),
191 );
192
193 expect(updated.ruleSet.ruleSetId).toBe(deployed.ruleSet.ruleSetId);
194 expect(updated.trafficPolicy.trafficPolicyId).toBe(
195 deployed.trafficPolicy.trafficPolicyId,
196 );
197 expect(updated.relay.relayId).toBe(deployed.relay.relayId);
198
199 const updatedRuleSet = yield* mm.getRuleSet({
200 RuleSetId: updated.ruleSet.ruleSetId,
201 });
202 expect(updatedRuleSet.Rules).toHaveLength(2);
203 const updatedPolicy = yield* mm.getTrafficPolicy({
204 TrafficPolicyId: updated.trafficPolicy.trafficPolicyId,
205 });
206 expect(updatedPolicy.DefaultAction).toBe("DENY");
207 expect(updatedPolicy.MaxMessageSizeBytes).toBe(5_242_880);
208 const updatedRelay = yield* mm.getRelay({
209 RelayId: updated.relay.relayId,
210 });
211 expect(updatedRelay.ServerPort).toBe(587);
212 const updatedRuleSetTags = yield* mm.listTagsForResource({
213 ResourceArn: updated.ruleSet.ruleSetArn,
214 });
215 expect(
216 updatedRuleSetTags.Tags?.some(
217 (t) => t.Key === "env" && t.Value === "test",
218 ),
219 ).toBe(true);
220
221 // Destroy — everything gone, typed.
222 yield* stack.destroy();
223 const ruleSetError = yield* Effect.flip(
224 mm.getRuleSet({ RuleSetId: updated.ruleSet.ruleSetId }),
225 );
226 expect(ruleSetError._tag).toBe("ResourceNotFoundException");
227 const policyError = yield* Effect.flip(
228 mm.getTrafficPolicy({
229 TrafficPolicyId: updated.trafficPolicy.trafficPolicyId,
230 }),
231 );
232 expect(policyError._tag).toBe("ResourceNotFoundException");
233 const relayError = yield* Effect.flip(
234 mm.getRelay({ RelayId: updated.relay.relayId }),
235 );
236 expect(relayError._tag).toBe("ResourceNotFoundException");
237 }),
238 {
239 tags: ["provider:aws", "provider:aws:mailmanager", "live"],
240 timeout: 240_000,
241 },
242);
243
244// Address lists and archives are free and provision instantly — full
245// lifecycle runs ungated. The destroy phase also pins the archive's
246// PENDING_DELETION semantics (deleted archives tombstone for 30 days) and
247// re-creates once to prove reruns don't collide with the tombstone.
248test.provider(
249 "lifecycle: address list + archive",
250 (stack) =>
251 Effect.gen(function* () {
252 yield* stack.destroy();
253
254 // Create.
255 const deployed = yield* stack.deploy(
256 Effect.gen(function* () {
257 const addressList = yield* AddressList("Members", {
258 tags: { fixture: "mailmanager" },
259 });
260 const archive = yield* Archive("Mail", {
261 retentionPeriod: "THREE_MONTHS",
262 tags: { fixture: "mailmanager" },
263 });
264 return { addressList, archive };
265 }),
266 );
267
268 expect(deployed.addressList.addressListId).toBeDefined();
269 expect(deployed.addressList.addressListArn).toContain("mailmanager");
270 expect(deployed.archive.archiveId).toBeDefined();
271 expect(deployed.archive.archiveArn).toContain("mailmanager");
272 expect(deployed.archive.archiveState).toBe("ACTIVE");
273
274 // Out-of-band verification via distilled.
275 const liveList = yield* mm.getAddressList({
276 AddressListId: deployed.addressList.addressListId,
277 });
278 expect(liveList.AddressListName).toBe(
279 deployed.addressList.addressListName,
280 );
281 const liveArchive = yield* mm.getArchive({
282 ArchiveId: deployed.archive.archiveId,
283 });
284 expect(liveArchive.ArchiveName).toBe(deployed.archive.archiveName);
285 expect(liveArchive.Retention?.RetentionPeriod).toBe("THREE_MONTHS");
286 const listTags = yield* mm.listTagsForResource({
287 ResourceArn: deployed.addressList.addressListArn,
288 });
289 expect(
290 listTags.Tags?.some(
291 (t) => t.Key === "alchemy::id" && t.Value === "Members",
292 ),
293 ).toBe(true);
294
295 // Update — archive retention changes in place; address list tags
296 // sync; identities are stable.
297 const updated = yield* stack.deploy(
298 Effect.gen(function* () {
299 const addressList = yield* AddressList("Members", {
300 tags: { fixture: "mailmanager", env: "test" },
301 });
302 const archive = yield* Archive("Mail", {
303 retentionPeriod: "SIX_MONTHS",
304 tags: { fixture: "mailmanager" },
305 });
306 return { addressList, archive };
307 }),
308 );
309
310 expect(updated.addressList.addressListId).toBe(
311 deployed.addressList.addressListId,
312 );
313 expect(updated.archive.archiveId).toBe(deployed.archive.archiveId);
314 const updatedArchive = yield* mm.getArchive({
315 ArchiveId: updated.archive.archiveId,
316 });
317 expect(updatedArchive.Retention?.RetentionPeriod).toBe("SIX_MONTHS");
318 const updatedListTags = yield* mm.listTagsForResource({
319 ResourceArn: updated.addressList.addressListArn,
320 });
321 expect(
322 updatedListTags.Tags?.some(
323 (t) => t.Key === "env" && t.Value === "test",
324 ),
325 ).toBe(true);
326
327 // Destroy — the list is gone (typed); the archive tombstones in
328 // PENDING_DELETION (or is already invisible).
329 yield* stack.destroy();
330 const listError = yield* Effect.flip(
331 mm.getAddressList({
332 AddressListId: updated.addressList.addressListId,
333 }),
334 );
335 expect(listError._tag).toBe("ResourceNotFoundException");
336 const archiveState = yield* mm
337 .getArchive({ ArchiveId: updated.archive.archiveId })
338 .pipe(
339 Effect.map((a) => a.ArchiveState),
340 Effect.catchTag("ResourceNotFoundException", () =>
341 Effect.succeed("PENDING_DELETION" as const),
342 ),
343 );
344 expect(archiveState).toBe("PENDING_DELETION");
345
346 // Re-create — proves a rerun does not collide with the
347 // pending-deletion tombstone of the same deterministic name.
348 const recreated = yield* stack.deploy(
349 Effect.gen(function* () {
350 const archive = yield* Archive("Mail", {
351 retentionPeriod: "THREE_MONTHS",
352 });
353 return { archive };
354 }),
355 );
356 expect(recreated.archive.archiveId).not.toBe(deployed.archive.archiveId);
357 expect(recreated.archive.archiveState).toBe("ACTIVE");
358
359 yield* stack.destroy();
360 }),
361 {
362 tags: ["provider:aws", "provider:aws:mailmanager", "live"],
363 timeout: 240_000,
364 },
365);
366
367// Ingress points provision an SMTP endpoint asynchronously (~1-2 min each
368// way) — the full lifecycle is gated behind AWS_TEST_MAILMANAGER=1.
369const assertIngressPointGone = (ingressPointId: string) =>
370 Effect.gen(function* () {
371 const status = yield* mm
372 .getIngressPoint({ IngressPointId: ingressPointId })
373 .pipe(
374 Effect.map((r) => r.Status ?? "unknown"),
375 Effect.catchTag("ResourceNotFoundException", () =>
376 Effect.succeed("gone" as const),
377 ),
378 );
379 if (status !== "gone" && status !== "DEPROVISIONING") {
380 return yield* Effect.fail(
381 new Error(`ingress point still exists (status: ${status})`),
382 );
383 }
384 }).pipe(
385 Effect.retry({
386 schedule: Schedule.max([
387 Schedule.fixed("10 seconds"),
388 Schedule.recurs(12),
389 ]),
390 }),
391 );
392
393test.provider.skipIf(!process.env.AWS_TEST_MAILMANAGER)(
394 "lifecycle: open ingress point",
395 (stack) =>
396 Effect.gen(function* () {
397 yield* stack.destroy();
398
399 const deployed = yield* stack.deploy(
400 Effect.gen(function* () {
401 const ruleSet = yield* RuleSet("IngressRules", {
402 rules: [{ Name: "DropAll", Actions: [{ Drop: {} }] }],
403 });
404 const trafficPolicy = yield* TrafficPolicy("IngressPolicy", {
405 defaultAction: "ALLOW",
406 });
407 const ingress = yield* IngressPoint("Smtp", {
408 type: "OPEN",
409 ruleSetId: ruleSet.ruleSetId,
410 trafficPolicyId: trafficPolicy.trafficPolicyId,
411 // An omitted TlsPolicy defaults to FIPS, and AWS rejects any
412 // update from or to FIPS — start in the updatable regime.
413 tlsPolicy: "REQUIRED",
414 tags: { fixture: "mailmanager" },
415 });
416 return { ruleSet, trafficPolicy, ingress };
417 }),
418 );
419
420 expect(deployed.ingress.ingressPointId).toBeDefined();
421 expect(deployed.ingress.ingressPointArn).toContain("mailmanager");
422
423 // Out-of-band verification via distilled.
424 const live = yield* mm.getIngressPoint({
425 IngressPointId: deployed.ingress.ingressPointId,
426 });
427 expect(live.Type).toBe("OPEN");
428 expect(live.RuleSetId).toBe(deployed.ruleSet.ruleSetId);
429 expect(live.TrafficPolicyId).toBe(deployed.trafficPolicy.trafficPolicyId);
430 expect(live.TlsPolicy).toBe("REQUIRED");
431
432 // Update in place — TLS policy changes REQUIRED -> OPTIONAL; identity
433 // is stable. Both dependencies stay deployed (engine replace+remove
434 // deadlock).
435 const updated = yield* stack.deploy(
436 Effect.gen(function* () {
437 const ruleSet = yield* RuleSet("IngressRules", {
438 rules: [{ Name: "DropAll", Actions: [{ Drop: {} }] }],
439 });
440 const trafficPolicy = yield* TrafficPolicy("IngressPolicy", {
441 defaultAction: "ALLOW",
442 });
443 const ingress = yield* IngressPoint("Smtp", {
444 type: "OPEN",
445 ruleSetId: ruleSet.ruleSetId,
446 trafficPolicyId: trafficPolicy.trafficPolicyId,
447 tlsPolicy: "OPTIONAL",
448 tags: { fixture: "mailmanager" },
449 });
450 return { ingress };
451 }),
452 );
453
454 expect(updated.ingress.ingressPointId).toBe(
455 deployed.ingress.ingressPointId,
456 );
457 const updatedLive = yield* mm.getIngressPoint({
458 IngressPointId: updated.ingress.ingressPointId,
459 });
460 expect(updatedLive.TlsPolicy).toBe("OPTIONAL");
461
462 // Destroy — deletion is asynchronous; verify it is at least
463 // irreversibly deprovisioning.
464 yield* stack.destroy();
465 yield* assertIngressPointGone(updated.ingress.ingressPointId);
466 }),
467 {
468 tags: ["provider:aws", "provider:aws:mailmanager", "live"],
469 timeout: 600_000,
470 },
471);
472
473// Add On subscriptions accept third-party terms of use and ADDITIONAL
474// PRICING — never created outside an explicitly opted-in account.
475test.provider.skipIf(!process.env.AWS_TEST_MAILMANAGER_ADDONS)(
476 "lifecycle: addon subscription + addon instance",
477 (stack) =>
478 Effect.gen(function* () {
479 yield* stack.destroy();
480
481 const deployed = yield* stack.deploy(
482 Effect.gen(function* () {
483 const subscription = yield* AddonSubscription("Spamhaus", {
484 addonName: "SPAMHAUS_DBL",
485 tags: { fixture: "mailmanager" },
486 });
487 const instance = yield* AddonInstance("SpamhausInstance", {
488 addonSubscriptionId: subscription.addonSubscriptionId,
489 tags: { fixture: "mailmanager" },
490 });
491 return { subscription, instance };
492 }),
493 );
494
495 expect(deployed.subscription.addonSubscriptionId).toBeDefined();
496 expect(deployed.subscription.addonName).toBe("SPAMHAUS_DBL");
497 expect(deployed.instance.addonInstanceId).toBeDefined();
498 expect(deployed.instance.addonSubscriptionId).toBe(
499 deployed.subscription.addonSubscriptionId,
500 );
501
502 // Out-of-band verification via distilled.
503 const liveSubscription = yield* mm.getAddonSubscription({
504 AddonSubscriptionId: deployed.subscription.addonSubscriptionId,
505 });
506 expect(liveSubscription.AddonName).toBe("SPAMHAUS_DBL");
507 const liveInstance = yield* mm.getAddonInstance({
508 AddonInstanceId: deployed.instance.addonInstanceId,
509 });
510 expect(liveInstance.AddonSubscriptionId).toBe(
511 deployed.subscription.addonSubscriptionId,
512 );
513
514 // Destroy — both gone, typed.
515 yield* stack.destroy();
516 const instanceError = yield* Effect.flip(
517 mm.getAddonInstance({
518 AddonInstanceId: deployed.instance.addonInstanceId,
519 }),
520 );
521 expect(instanceError._tag).toBe("ResourceNotFoundException");
522 const subscriptionError = yield* Effect.flip(
523 mm.getAddonSubscription({
524 AddonSubscriptionId: deployed.subscription.addonSubscriptionId,
525 }),
526 );
527 expect(subscriptionError._tag).toBe("ResourceNotFoundException");
528 }),
529 {
530 tags: ["provider:aws", "provider:aws:mailmanager", "live"],
531 timeout: 240_000,
532 },
533);