1import * as AWS from "@/AWS";
2import { ConfigurationSet, EmailIdentity } from "@/AWS/SES";
3import * as Test from "@/Test/Alchemy";
4import * as sesv2 from "@distilled.cloud/aws/sesv2";
5import { expect } from "alchemy-test";
6import * as Data from "effect/Data";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9
10const { test } = Test.make({ providers: AWS.providers() });
11
12// Deterministic identity names — never verified, creation alone is the test.
13const TEST_DOMAIN = "ses-identity.alchemy-test.example.com";
14const TEST_DOMAIN_B = "ses-identity-b.alchemy-test.example.com";
15const TEST_DOMAIN_TOGGLES = "ses-identity-toggles.alchemy-test.example.com";
16const TEST_EMAIL = "alchemy-ses-test@example.com";
17
18// A custom MAIL FROM domain only takes effect on a domain we actually own,
19// with MX and SPF records published for the subdomain. Point these at such a
20// pair to exercise the mailFrom sync.
21const MAIL_FROM_IDENTITY = process.env.AWS_TEST_SES_MAIL_FROM_IDENTITY;
22const MAIL_FROM_DOMAIN = process.env.AWS_TEST_SES_MAIL_FROM_DOMAIN;
23
24class IdentityStillExists extends Data.TaggedError("IdentityStillExists")<{
25 readonly name: string;
26}> {}
27
28const assertIdentityDeleted = (name: string) =>
29 sesv2.getEmailIdentity({ EmailIdentity: name }).pipe(
30 Effect.flatMap(() => Effect.fail(new IdentityStillExists({ name }))),
31 Effect.catchTag("NotFoundException", () => Effect.void),
32 Effect.retry({
33 while: (e) => e._tag === "IdentityStillExists",
34 schedule: Schedule.max([Schedule.exponential(500), Schedule.recurs(8)]),
35 }),
36 );
37
38test.provider(
39 "domain identity lifecycle: DKIM tokens, tags, replacement",
40 (stack) =>
41 Effect.gen(function* () {
42 yield* stack.destroy();
43
44 const identity = yield* stack.deploy(
45 Effect.gen(function* () {
46 return yield* EmailIdentity("DomainIdentity", {
47 emailIdentity: TEST_DOMAIN,
48 tags: { Environment: "test" },
49 });
50 }),
51 );
52
53 expect(identity.emailIdentity).toBe(TEST_DOMAIN);
54 expect(identity.identityArn).toContain(`:identity/${TEST_DOMAIN}`);
55 expect(identity.identityType).toBe("DOMAIN");
56 // sandbox: DKIM tokens are returned immediately, verification stays
57 // pending — never block on VerificationStatus=SUCCESS.
58 expect(identity.dkimTokens).toHaveLength(3);
59 expect(identity.verifiedForSendingStatus).toBe(false);
60
61 // out-of-band verification via distilled
62 const observed = yield* sesv2.getEmailIdentity({
63 EmailIdentity: TEST_DOMAIN,
64 });
65 expect(observed.IdentityType).toBe("DOMAIN");
66 const tags = Object.fromEntries(
67 (observed.Tags ?? []).map((t) => [t.Key, t.Value]),
68 );
69 expect(tags.Environment).toBe("test");
70 expect(tags["alchemy::id"]).toBe("DomainIdentity");
71
72 // update tags in place
73 yield* stack.deploy(
74 Effect.gen(function* () {
75 return yield* EmailIdentity("DomainIdentity", {
76 emailIdentity: TEST_DOMAIN,
77 tags: { Environment: "test", Extra: "1" },
78 });
79 }),
80 );
81 const afterTagUpdate = yield* sesv2.getEmailIdentity({
82 EmailIdentity: TEST_DOMAIN,
83 });
84 const updatedTags = Object.fromEntries(
85 (afterTagUpdate.Tags ?? []).map((t) => [t.Key, t.Value]),
86 );
87 expect(updatedTags.Extra).toBe("1");
88
89 // changing the identity replaces it
90 const replaced = yield* stack.deploy(
91 Effect.gen(function* () {
92 return yield* EmailIdentity("DomainIdentity", {
93 emailIdentity: TEST_DOMAIN_B,
94 tags: { Environment: "test" },
95 });
96 }),
97 );
98 expect(replaced.emailIdentity).toBe(TEST_DOMAIN_B);
99 yield* assertIdentityDeleted(TEST_DOMAIN);
100
101 yield* stack.destroy();
102 yield* assertIdentityDeleted(TEST_DOMAIN_B);
103 }),
104 { tags: ["provider:aws", "provider:aws:ses", "live"], timeout: 120_000 },
105);
106
107test.provider(
108 "email-address identity is created pending verification",
109 (stack) =>
110 Effect.gen(function* () {
111 yield* stack.destroy();
112
113 const identity = yield* stack.deploy(
114 Effect.gen(function* () {
115 return yield* EmailIdentity("AddressIdentity", {
116 emailIdentity: TEST_EMAIL,
117 });
118 }),
119 );
120
121 expect(identity.identityType).toBe("EMAIL_ADDRESS");
122 // sandbox: verification email goes out; the identity exists but is
123 // not verified for sending — assert pending, never wait for SUCCESS.
124 expect(identity.verifiedForSendingStatus).toBe(false);
125
126 yield* stack.destroy();
127 yield* assertIdentityDeleted(TEST_EMAIL);
128 }),
129 { tags: ["provider:aws", "provider:aws:ses", "live"], timeout: 120_000 },
130);
131
132test.provider(
133 "identity with configuration-set association",
134 (stack) =>
135 Effect.gen(function* () {
136 yield* stack.destroy();
137
138 const { identity, configSet } = yield* stack.deploy(
139 Effect.gen(function* () {
140 const configSet = yield* ConfigurationSet("IdentityConfigSet", {});
141 const identity = yield* EmailIdentity("AssociatedIdentity", {
142 emailIdentity: "ses-assoc.alchemy-test.example.com",
143 configurationSetName: configSet.configurationSetName,
144 });
145 return { identity, configSet };
146 }),
147 );
148
149 const observed = yield* sesv2.getEmailIdentity({
150 EmailIdentity: identity.emailIdentity,
151 });
152 expect(observed.ConfigurationSetName).toBe(
153 configSet.configurationSetName,
154 );
155
156 // remove the association in place (keep the config set deployed so
157 // the update never races a dependency removal)
158 yield* stack.deploy(
159 Effect.gen(function* () {
160 const configSet = yield* ConfigurationSet("IdentityConfigSet", {});
161 const identity = yield* EmailIdentity("AssociatedIdentity", {
162 emailIdentity: "ses-assoc.alchemy-test.example.com",
163 });
164 return { identity, configSet };
165 }),
166 );
167 const afterRemoval = yield* sesv2.getEmailIdentity({
168 EmailIdentity: identity.emailIdentity,
169 });
170 expect(afterRemoval.ConfigurationSetName).toBeUndefined();
171
172 yield* stack.destroy();
173 yield* assertIdentityDeleted(identity.emailIdentity);
174 }),
175 { tags: ["provider:aws", "provider:aws:ses", "live"], timeout: 120_000 },
176);
177
178// The DKIM-signing and feedback-forwarding toggles apply to any identity, so
179// this runs ungated on the standing (never-verified) test domain.
180test.provider(
181 "syncs DKIM signing and feedback forwarding in place",
182 (stack) =>
183 Effect.gen(function* () {
184 yield* stack.destroy();
185
186 const identity = yield* stack.deploy(
187 Effect.gen(function* () {
188 return yield* EmailIdentity("TogglesIdentity", {
189 emailIdentity: TEST_DOMAIN_TOGGLES,
190 dkimSigningEnabled: true,
191 feedbackForwardingEnabled: true,
192 });
193 }),
194 );
195
196 const created = yield* sesv2.getEmailIdentity({
197 EmailIdentity: identity.emailIdentity,
198 });
199 expect(created.DkimAttributes?.SigningEnabled).toBe(true);
200 expect(created.FeedbackForwardingStatus).toBe(true);
201
202 // flip both off in place — no replacement
203 yield* stack.deploy(
204 Effect.gen(function* () {
205 return yield* EmailIdentity("TogglesIdentity", {
206 emailIdentity: TEST_DOMAIN_TOGGLES,
207 dkimSigningEnabled: false,
208 feedbackForwardingEnabled: false,
209 });
210 }),
211 );
212 const toggled = yield* sesv2.getEmailIdentity({
213 EmailIdentity: identity.emailIdentity,
214 });
215 expect(toggled.DkimAttributes?.SigningEnabled).toBe(false);
216 expect(toggled.FeedbackForwardingStatus).toBe(false);
217
218 // omitting the props must leave SES's current setting untouched
219 yield* stack.deploy(
220 Effect.gen(function* () {
221 return yield* EmailIdentity("TogglesIdentity", {
222 emailIdentity: TEST_DOMAIN_TOGGLES,
223 });
224 }),
225 );
226 const afterOmit = yield* sesv2.getEmailIdentity({
227 EmailIdentity: identity.emailIdentity,
228 });
229 expect(afterOmit.DkimAttributes?.SigningEnabled).toBe(false);
230 expect(afterOmit.FeedbackForwardingStatus).toBe(false);
231
232 yield* stack.destroy();
233 yield* assertIdentityDeleted(identity.emailIdentity);
234 }),
235 { tags: ["provider:aws", "provider:aws:ses", "live"], timeout: 120_000 },
236);
237
238// A custom MAIL FROM domain must be a subdomain of a real identity with MX and
239// SPF published, so the sync is only observable on a domain we control.
240test.provider.skipIf(!MAIL_FROM_DOMAIN || !MAIL_FROM_IDENTITY)(
241 "syncs a custom MAIL FROM domain (AWS_TEST_SES_MAIL_FROM_IDENTITY + _DOMAIN)",
242 (stack) =>
243 Effect.gen(function* () {
244 yield* stack.destroy();
245
246 const identity = yield* stack.deploy(
247 Effect.gen(function* () {
248 return yield* EmailIdentity("MailFromIdentity", {
249 emailIdentity: MAIL_FROM_IDENTITY!,
250 mailFromDomain: MAIL_FROM_DOMAIN,
251 mailFromBehaviorOnMxFailure: "USE_DEFAULT_VALUE",
252 });
253 }),
254 );
255
256 const created = yield* sesv2.getEmailIdentity({
257 EmailIdentity: identity.emailIdentity,
258 });
259 expect(created.MailFromAttributes?.MailFromDomain).toBe(MAIL_FROM_DOMAIN);
260 expect(created.MailFromAttributes?.BehaviorOnMxFailure).toBe(
261 "USE_DEFAULT_VALUE",
262 );
263
264 // change only the MX-failure behavior — applied in place
265 yield* stack.deploy(
266 Effect.gen(function* () {
267 return yield* EmailIdentity("MailFromIdentity", {
268 emailIdentity: MAIL_FROM_IDENTITY!,
269 mailFromDomain: MAIL_FROM_DOMAIN,
270 mailFromBehaviorOnMxFailure: "REJECT_MESSAGE",
271 });
272 }),
273 );
274 const updated = yield* sesv2.getEmailIdentity({
275 EmailIdentity: identity.emailIdentity,
276 });
277 expect(updated.MailFromAttributes?.BehaviorOnMxFailure).toBe(
278 "REJECT_MESSAGE",
279 );
280
281 yield* stack.destroy();
282 yield* assertIdentityDeleted(identity.emailIdentity);
283 }),
284 { tags: ["provider:aws", "provider:aws:ses", "live"], timeout: 120_000 },
285);