1import { adopt } from "@/AdoptPolicy";
2import * as AWS from "@/AWS";
3import {
4 ActiveReceiptRuleSet,
5 EmailIdentity,
6 ReceiptRule,
7 ReceiptRuleSet,
8} from "@/AWS/SES";
9import * as Cloudflare from "@/Cloudflare";
10import { CloudflareEnvironment } from "@/Cloudflare/CloudflareEnvironment";
11import { findZoneByName } from "@/Cloudflare/Zone/lookup";
12import * as Output from "@/Output";
13import * as Test from "@/Test/Alchemy";
14import * as s3 from "@distilled.cloud/aws/s3";
15import * as ses from "@distilled.cloud/aws/ses";
16import * as sesv2 from "@distilled.cloud/aws/sesv2";
17import * as sts from "@distilled.cloud/aws/sts";
18import { expect } from "alchemy-test";
19import * as Effect from "effect/Effect";
20import * as Layer from "effect/Layer";
21import * as Schedule from "effect/Schedule";
22import * as Stream from "effect/Stream";
23
24const { test } = Test.make({
25 providers: Layer.mergeAll(AWS.providers(), Cloudflare.providers()),
26});
27
28// End-to-end email receiving: verified domain + MX -> SES inbound -> receipt
29// rule -> S3. The loop is fully automatable (the SES sandbox allows sends both
30// FROM and TO a verified domain identity, and receiving itself never requires
31// verification), but Easy-DKIM detection takes minutes and the suite needs the
32// standing Cloudflare test zone + Cloudflare credentials — so the test is
33// env-gated per the speed doctrine. Run with:
34//
35// AWS_TEST_SES_RECEIVING=1 bun run test test/AWS/SES/Receiving.smoke.test.ts --profile testing
36const GATED = !process.env.AWS_TEST_SES_RECEIVING;
37
38const zoneName =
39 process.env.CLOUDFLARE_TEST_DNS_ZONE_NAME ?? "alchemy-test-2.us";
40// SES receiving is regional; the testing profile deploys to us-west-2 (one of
41// the receiving-supported regions).
42const region = process.env.AWS_TEST_SES_RECEIVING_REGION ?? "us-west-2";
43
44// Deterministic names — same on every run, disjoint from other suites.
45const domain = `ses-rcv-e2e.${zoneName}`;
46const recipient = `inbox@${domain}`;
47const SUBJECT_MARKER = "alchemy-ses-receiving-e2e";
48
49const resolveZoneId = Effect.gen(function* () {
50 const { accountId } = yield* yield* CloudflareEnvironment;
51 const zone = yield* findZoneByName({ accountId, name: zoneName });
52 if (!zone) {
53 return yield* Effect.die(
54 new Error(`zone "${zoneName}" not found in account`),
55 );
56 }
57 return zone.id;
58});
59
60const activeRuleSetName = ses
61 .describeActiveReceiptRuleSet({})
62 .pipe(Effect.map((response) => response.Metadata?.Name));
63
64// Mutates the account-singleton active receipt rule set: exclusive, and the
65// previously active set (if any) is captured and restored.
66test.provider.skipIf(GATED)(
67 "end-to-end receive: MX -> SES inbound -> receipt rule -> S3 object",
68 (stack) =>
69 Effect.gen(function* () {
70 const zoneId = yield* resolveZoneId;
71 const { Account } = yield* sts.getCallerIdentity({});
72 const bucketName = `alchemy-ses-rcv-e2e-${Account}`;
73 const captured = yield* activeRuleSetName;
74
75 yield* Effect.gen(function* () {
76 yield* stack.destroy();
77
78 yield* stack.deploy(
79 Effect.gen(function* () {
80 // Domain identity; Easy DKIM CNAMEs published to the test zone
81 // verify it for the sandbox send below. Receiving itself does not
82 // require verification.
83 const identity = yield* EmailIdentity("RcvIdentity", {
84 emailIdentity: domain,
85 });
86 const dkim = (index: number) =>
87 Output.all(identity.dkimTokens).pipe(
88 Output.map(([tokens]) => tokens[index]!),
89 );
90 // DNS records carry no ownership tags, so adopt-by-name is the
91 // standing-zone convention (see DnsRecord.test.ts) — it also
92 // converges over records orphaned by an interrupted run.
93 for (const index of [0, 1, 2]) {
94 yield* Cloudflare.DNS.Record(`Dkim${index}`, {
95 zoneId,
96 name: dkim(index).pipe(
97 Output.map((token) => `${token}._domainkey.${domain}`),
98 ),
99 type: "CNAME",
100 content: dkim(index).pipe(
101 Output.map((token) => `${token}.dkim.amazonses.com`),
102 ),
103 ttl: 60,
104 }).pipe(adopt(true));
105 }
106
107 // Route the subdomain's mail to SES inbound.
108 yield* Cloudflare.DNS.Record("Mx", {
109 zoneId,
110 name: domain,
111 type: "MX",
112 content: `inbound-smtp.${region}.amazonaws.com`,
113 priority: 10,
114 ttl: 60,
115 }).pipe(adopt(true));
116
117 // Inbound bucket. SES receiving writes via the bucket policy (no
118 // IAM role involved), so the policy must grant ses.amazonaws.com
119 // before the receipt rule below validates it.
120 const bucket = yield* AWS.S3.Bucket("RcvBucket", {
121 bucketName,
122 forceDestroy: true,
123 policy: [
124 {
125 Sid: "AllowSESPuts",
126 Effect: "Allow",
127 Principal: { Service: "ses.amazonaws.com" },
128 Action: ["s3:PutObject"],
129 Resource: `arn:aws:s3:::${bucketName}/*`,
130 Condition: {
131 StringEquals: { "aws:SourceAccount": Account! },
132 },
133 },
134 ],
135 });
136
137 const ruleSet = yield* ReceiptRuleSet("RcvSet", {});
138 yield* ReceiptRule("RcvRule", {
139 ruleSetName: ruleSet.ruleSetName,
140 recipients: [domain],
141 scanEnabled: true,
142 actions: [
143 {
144 S3Action: {
145 // Reference the output (not the literal) so the rule
146 // deploys after the bucket + policy exist — SES validates
147 // the grant at createReceiptRule time.
148 BucketName: bucket.bucketName,
149 ObjectKeyPrefix: "inbound/",
150 },
151 },
152 ],
153 });
154 yield* ActiveReceiptRuleSet("RcvActive", {
155 ruleSetName: ruleSet.ruleSetName,
156 });
157 }),
158 );
159
160 // Wait for Easy DKIM detection (SES polls the published CNAMEs;
161 // typically 1-3 minutes with Cloudflare's fast propagation).
162 const verified = yield* sesv2
163 .getEmailIdentity({ EmailIdentity: domain })
164 .pipe(
165 Effect.repeat({
166 schedule: Schedule.spaced("10 seconds"),
167 until: (identity): boolean =>
168 identity.DkimAttributes?.Status === "SUCCESS",
169 times: 48,
170 }),
171 );
172 expect(verified.DkimAttributes?.Status).toBe("SUCCESS");
173
174 // The first delivery attempt can hard-bounce while SES inbound is
175 // still propagating the freshly activated rule set — and a hard
176 // bounce auto-adds the recipient to the ACCOUNT-level suppression
177 // list, silently swallowing every later send (the entry outlives
178 // stack.destroy(), so it would poison reruns too). Clear it before
179 // each attempt and re-send in bounded rounds until the marked
180 // message lands.
181 const unsuppress = sesv2
182 .deleteSuppressedDestination({ EmailAddress: recipient })
183 .pipe(Effect.catchTag("NotFoundException", () => Effect.void));
184
185 // A bounced first attempt also routes the MAILER-DAEMON bounce back
186 // through the rule into the bucket, so match the marker subject in
187 // the raw message rather than counting objects.
188 const findMarked = Effect.gen(function* () {
189 const listing = yield* s3.listObjectsV2({
190 Bucket: bucketName,
191 Prefix: "inbound/",
192 });
193 const keys = (listing.Contents ?? [])
194 .map((object) => object.Key!)
195 .filter((key) => !key.includes("AMAZON_SES_SETUP_NOTIFICATION"));
196 for (const key of keys) {
197 const raw = yield* s3
198 .getObject({ Bucket: bucketName, Key: key })
199 .pipe(
200 Effect.flatMap((object) =>
201 Stream.mkString(Stream.decodeText(object.Body!)),
202 ),
203 );
204 if (raw.includes(`Subject: ${SUBJECT_MARKER}`)) return raw;
205 }
206 return undefined;
207 });
208
209 const attemptDelivery = Effect.gen(function* () {
210 yield* unsuppress;
211 // Sandbox allows both legs because the domain identity is now
212 // verified; verification propagates eventually, so retry the typed
213 // MessageRejected briefly.
214 yield* sesv2
215 .sendEmail({
216 FromEmailAddress: `sender@${domain}`,
217 Destination: { ToAddresses: [recipient] },
218 Content: {
219 Simple: {
220 Subject: { Data: SUBJECT_MARKER },
221 Body: {
222 Text: { Data: "Round trip through SES email receiving." },
223 },
224 },
225 },
226 })
227 .pipe(
228 Effect.retry({
229 while: (error) => error._tag === "MessageRejected",
230 schedule: Schedule.max([
231 Schedule.spaced("5 seconds"),
232 Schedule.recurs(12),
233 ]),
234 }),
235 );
236 return yield* findMarked.pipe(
237 Effect.repeat({
238 schedule: Schedule.spaced("5 seconds"),
239 until: (raw): boolean => raw !== undefined,
240 times: 12,
241 }),
242 );
243 });
244
245 const raw = yield* attemptDelivery.pipe(
246 Effect.repeat({
247 schedule: Schedule.spaced("5 seconds"),
248 until: (found): boolean => found !== undefined,
249 times: 4,
250 }),
251 );
252 expect(raw).toBeDefined();
253 expect(raw).toContain(`Subject: ${SUBJECT_MARKER}`);
254 expect(raw).toContain(`To: ${recipient}`);
255
256 yield* stack.destroy();
257 }).pipe(
258 Effect.ensuring(
259 Effect.all([
260 captured
261 ? ses
262 .setActiveReceiptRuleSet({ RuleSetName: captured })
263 .pipe(Effect.ignore)
264 : Effect.void,
265 // Leave the account suppression list the way we found it.
266 sesv2
267 .deleteSuppressedDestination({ EmailAddress: recipient })
268 .pipe(Effect.ignore),
269 ]),
270 ),
271 );
272 }),
273 {
274 tags: [
275 "provider:aws",
276 "provider:aws:s3",
277 "provider:aws:ses",
278 "provider:cloudflare",
279 "provider:cloudflare:dns",
280 "provider:cloudflare:zone",
281 "live",
282 ],
283 timeout: 900_000,
284 exclusive: true,
285 },
286);