1import * as AWS from "@/AWS";
2import { ReceiptRule, ReceiptRuleSet } from "@/AWS/SES";
3import * as Test from "@/Test/Alchemy";
4import * as ses from "@distilled.cloud/aws/ses";
5import { expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as Result from "effect/Result";
9import * as Schedule from "effect/Schedule";
10
11const { test } = Test.make({ providers: AWS.providers() });
12
13class RuleStillExists extends Data.TaggedError("RuleStillExists")<{
14 readonly ruleSetName: string;
15 readonly ruleName: string;
16}> {}
17
18const assertRuleDeleted = (ruleSetName: string, ruleName: string) =>
19 ses
20 .describeReceiptRule({ RuleSetName: ruleSetName, RuleName: ruleName })
21 .pipe(
22 Effect.flatMap(() =>
23 Effect.fail(new RuleStillExists({ ruleSetName, ruleName })),
24 ),
25 Effect.catchTag(
26 ["RuleDoesNotExistException", "RuleSetDoesNotExistException"],
27 () => Effect.void,
28 ),
29 Effect.retry({
30 while: (e) => e._tag === "RuleStillExists",
31 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(8)]),
32 }),
33 );
34
35test.provider(
36 "receipt rule lifecycle: create with actions, update in place, delete",
37 (stack) =>
38 Effect.gen(function* () {
39 yield* stack.destroy();
40
41 const { ruleSet, rule } = yield* stack.deploy(
42 Effect.gen(function* () {
43 const ruleSet = yield* ReceiptRuleSet("RuleLifecycleSet", {});
44 const rule = yield* ReceiptRule("Rule", {
45 ruleSetName: ruleSet.ruleSetName,
46 recipients: ["support@ses-bindings.alchemy-test.example.com"],
47 scanEnabled: true,
48 actions: [
49 {
50 AddHeaderAction: {
51 HeaderName: "X-Alchemy-Test",
52 HeaderValue: "inbound",
53 },
54 },
55 { StopAction: { Scope: "RuleSet" } },
56 ],
57 });
58 return { ruleSet, rule };
59 }),
60 );
61
62 expect(rule.ruleName).toBeDefined();
63 expect(rule.ruleSetName).toBe(ruleSet.ruleSetName);
64
65 // out-of-band verification via distilled
66 const observed = yield* ses.describeReceiptRule({
67 RuleSetName: ruleSet.ruleSetName,
68 RuleName: rule.ruleName,
69 });
70 expect(observed.Rule?.Name).toBe(rule.ruleName);
71 // `enabled` was omitted: the provider must apply the documented default
72 // (the wire API defaults an omitted Enabled to false, which would
73 // silently bounce all inbound mail).
74 expect(observed.Rule?.Enabled).toBe(true);
75 expect(observed.Rule?.ScanEnabled).toBe(true);
76 expect(observed.Rule?.Actions?.[0]?.AddHeaderAction?.HeaderValue).toBe(
77 "inbound",
78 );
79 expect(observed.Rule?.Recipients).toEqual([
80 "support@ses-bindings.alchemy-test.example.com",
81 ]);
82
83 // update in place: swap the action set, disable scanning, require TLS.
84 // (A BounceAction is not usable here — SES validates its Sender against
85 // the account's verified identities at update time; see the probe test
86 // below.)
87 yield* stack.deploy(
88 Effect.gen(function* () {
89 const ruleSet = yield* ReceiptRuleSet("RuleLifecycleSet", {});
90 yield* ReceiptRule("Rule", {
91 ruleSetName: ruleSet.ruleSetName,
92 enabled: false,
93 scanEnabled: false,
94 tlsPolicy: "Require",
95 actions: [
96 {
97 AddHeaderAction: {
98 HeaderName: "X-Alchemy-Test",
99 HeaderValue: "updated",
100 },
101 },
102 ],
103 });
104 }),
105 );
106 const updated = yield* ses.describeReceiptRule({
107 RuleSetName: ruleSet.ruleSetName,
108 RuleName: rule.ruleName,
109 });
110 expect(updated.Rule?.Enabled).toBe(false);
111 expect(updated.Rule?.ScanEnabled).toBe(false);
112 expect(updated.Rule?.TlsPolicy).toBe("Require");
113 expect(updated.Rule?.Actions).toHaveLength(1);
114 expect(updated.Rule?.Actions?.[0]?.AddHeaderAction?.HeaderValue).toBe(
115 "updated",
116 );
117
118 yield* stack.destroy();
119 yield* assertRuleDeleted(ruleSet.ruleSetName, rule.ruleName);
120 }),
121 { tags: ["provider:aws", "provider:aws:ses", "live"], timeout: 120_000 },
122);
123
124test.provider(
125 "renaming a rule replaces it within the same rule set",
126 (stack) =>
127 Effect.gen(function* () {
128 yield* stack.destroy();
129
130 // The rule set stays deployed across the replacement so the engine never
131 // has to remove a dependency of the resource being replaced.
132 const first = yield* stack.deploy(
133 Effect.gen(function* () {
134 const ruleSet = yield* ReceiptRuleSet("RenameSet", {});
135 const rule = yield* ReceiptRule("RenameRule", {
136 ruleSetName: ruleSet.ruleSetName,
137 ruleName: "alchemy-test-rule-a",
138 actions: [{ StopAction: { Scope: "RuleSet" } }],
139 });
140 return { ruleSetName: ruleSet.ruleSetName, ruleName: rule.ruleName };
141 }),
142 );
143 expect(first.ruleName).toBe("alchemy-test-rule-a");
144
145 const second = yield* stack.deploy(
146 Effect.gen(function* () {
147 const ruleSet = yield* ReceiptRuleSet("RenameSet", {});
148 const rule = yield* ReceiptRule("RenameRule", {
149 ruleSetName: ruleSet.ruleSetName,
150 ruleName: "alchemy-test-rule-b",
151 actions: [{ StopAction: { Scope: "RuleSet" } }],
152 });
153 return { ruleSetName: ruleSet.ruleSetName, ruleName: rule.ruleName };
154 }),
155 );
156 expect(second.ruleName).toBe("alchemy-test-rule-b");
157 yield* assertRuleDeleted(first.ruleSetName, "alchemy-test-rule-a");
158
159 yield* stack.destroy();
160 yield* assertRuleDeleted(second.ruleSetName, "alchemy-test-rule-b");
161 }),
162 { tags: ["provider:aws", "provider:aws:ses", "live"], timeout: 120_000 },
163);
164
165test.provider(
166 "rules are ordered by the `after` position",
167 (stack) =>
168 Effect.gen(function* () {
169 yield* stack.destroy();
170
171 const { ruleSetName } = yield* stack.deploy(
172 Effect.gen(function* () {
173 const ruleSet = yield* ReceiptRuleSet("OrderSet", {});
174 const first = yield* ReceiptRule("First", {
175 ruleSetName: ruleSet.ruleSetName,
176 ruleName: "alchemy-test-order-first",
177 actions: [{ StopAction: { Scope: "RuleSet" } }],
178 });
179 yield* ReceiptRule("Second", {
180 ruleSetName: ruleSet.ruleSetName,
181 ruleName: "alchemy-test-order-second",
182 after: first.ruleName,
183 actions: [{ StopAction: { Scope: "RuleSet" } }],
184 });
185 return { ruleSetName: ruleSet.ruleSetName };
186 }),
187 );
188
189 // out-of-band: the rule set lists rules in evaluation order
190 const observed = yield* ses.describeReceiptRuleSet({
191 RuleSetName: ruleSetName,
192 });
193 const order = (observed.Rules ?? []).map((r) => r.Name);
194 expect(order.indexOf("alchemy-test-order-first")).toBeLessThan(
195 order.indexOf("alchemy-test-order-second"),
196 );
197
198 yield* stack.destroy();
199 yield* assertRuleDeleted(ruleSetName, "alchemy-test-order-first");
200 }),
201 { tags: ["provider:aws", "provider:aws:ses", "live"], timeout: 120_000 },
202);
203
204// Ungated probe: SES validates a BounceAction's Sender against the account's
205// verified identities at create/update time. In the sandbox (no verified
206// identities) that rejection surfaces as the typed IdentityNotVerified tag —
207// carved out of the classic API's untyped InvalidParameterValue by the
208// distilled ses patch. This pins the patch forever at near-zero cost; on an
209// account where the identity IS verified the create simply succeeds.
210test.provider(
211 "a BounceAction with an unverified Sender fails with the typed IdentityNotVerified tag",
212 () =>
213 Effect.gen(function* () {
214 const ruleSetName = "alchemy-test-bounce-sender-probe";
215 yield* ses
216 .createReceiptRuleSet({ RuleSetName: ruleSetName })
217 .pipe(Effect.catchTag("AlreadyExistsException", () => Effect.void));
218
219 const created = yield* Effect.result(
220 ses.createReceiptRule({
221 RuleSetName: ruleSetName,
222 Rule: {
223 Name: "probe",
224 Actions: [
225 {
226 BounceAction: {
227 SmtpReplyCode: "550",
228 Message: "Mailbox does not exist",
229 Sender: "mailer-daemon@ses-bindings.alchemy-test.example.com",
230 },
231 },
232 ],
233 },
234 }),
235 );
236 if (Result.isFailure(created)) {
237 expect(created.failure._tag).toBe("IdentityNotVerified");
238 }
239 }).pipe(
240 Effect.ensuring(
241 ses
242 .deleteReceiptRuleSet({
243 RuleSetName: "alchemy-test-bounce-sender-probe",
244 })
245 .pipe(Effect.ignore),
246 ),
247 ),
248 { tags: ["provider:aws", "provider:aws:ses", "live"], timeout: 60_000 },
249);