1import * as Lambda from "@/AWS/Lambda";
2import * as SES from "@/AWS/SES";
3import * as Output from "@/Output";
4import * as Duration from "effect/Duration";
5import * as Effect from "effect/Effect";
6import * as Layer from "effect/Layer";
7import * as Stream from "effect/Stream";
8import { HttpServerRequest } from "effect/http/HttpServerRequest";
9import * as HttpServerResponse from "effect/http/HttpServerResponse";
10import path from "pathe";
11
12const main = path.resolve(import.meta.dirname, "handler.ts");
13
14// The SES mailbox simulator accepts mail even in the sandbox.
15const SIMULATOR = "success@simulator.amazonses.com";
16
17// The address the gated test starts verification FOR. SES authorizes
18// ses:SendCustomVerificationEmail against the identity of the address in the
19// request — the recipient — so the gated real-send test needs a binding scoped
20// to that address, not to the template's sender.
21//
22// Referenced, never managed: no EmailIdentity resource is declared for it, so
23// nothing adopts or destroys account state. Read at module load, which happens
24// both locally at deploy and inside the Lambda, so it is forwarded as env.
25const CVE_RECIPIENT = process.env.AWS_TEST_SES_CVE_RECIPIENT;
26
27export class SESTestFunction extends Lambda.Function<Lambda.Function>()(
28 "SESTestFunction",
29) {}
30
31export default SESTestFunction.make(
32 {
33 main,
34 functionUrl: true,
35 timeout: Duration.seconds(30),
36 // The gate is read at module load, which happens BOTH locally at deploy
37 // (where the binding is declared) and inside the Lambda (where the client
38 // is constructed). Forward it so the deployed function sees the same
39 // value the deploy did.
40 ...(CVE_RECIPIENT
41 ? { env: { AWS_TEST_SES_CVE_RECIPIENT: CVE_RECIPIENT } }
42 : {}),
43 },
44 Effect.gen(function* () {
45 // Domain identity — deterministic, never verified. In the SES sandbox a
46 // send from it fails with a typed MessageRejected; the ungated test
47 // asserts exactly that tag. A verified from-address can be supplied per
48 // request (?from=...) to exercise the success path.
49 const identity = yield* SES.EmailIdentity("SendIdentity", {
50 emailIdentity: "ses-bindings.alchemy-test.example.com",
51 });
52 const configSet = yield* SES.ConfigurationSet("SendConfigSet", {});
53 const template = yield* SES.EmailTemplate("SendTemplate", {
54 subject: "Hello, {{name}}!",
55 text: "Hi {{name}}.",
56 });
57
58 // Publish send/bounce/complaint events for this configuration set to the
59 // default EventBridge bus so `consumeEmailEvents` below has a producer.
60 yield* SES.ConfigurationSetEventDestination("ToEventBridge", {
61 configurationSetName: configSet.configurationSetName,
62 matchingEventTypes: ["SEND", "DELIVERY", "BOUNCE", "COMPLAINT"],
63 eventBridgeDestination: {
64 eventBusArn: Output.all(configSet.configurationSetArn).pipe(
65 Output.map(([arn]) =>
66 arn
67 .replace(":ses:", ":events:")
68 .replace(/:configuration-set\/.*$/, ":event-bus/default"),
69 ),
70 ),
71 },
72 });
73
74 // Event source: subscribe the host to SES email events. The deploy
75 // proves the EventBridge rule + invoke permission wiring; events only
76 // flow for real sends (gated behind AWS_TEST_SES_FROM in the tests).
77 yield* SES.consumeEmailEvents(
78 { kinds: ["send", "delivery", "bounce", "complaint"] },
79 (events) =>
80 Stream.runForEach(events, (event) =>
81 Effect.log(
82 `ses email event: ${event["detail-type"]} (${event.detail.mail?.messageId})`,
83 ),
84 ),
85 );
86
87 // No custom verification email template is declared here: SES rejects
88 // CreateCustomVerificationEmailTemplate unless its FromEmailAddress is
89 // already a verified identity, which a bare account has none of, and a
90 // failed create here would take down every binding test in the file. The
91 // template name is a request parameter instead — see /send-custom-verification.
92
93 const sendEmail = yield* SES.SendEmail(identity, configSet);
94 const sendWithoutConfigSet = yield* SES.SendEmail(identity);
95 const sendBulkEmail = yield* SES.SendBulkEmail(identity, configSet);
96 const renderTemplate = yield* SES.RenderEmailTemplate(template);
97 const getAccount = yield* SES.GetAccount();
98 const suppress = yield* SES.PutSuppressedDestination();
99 const getSuppressed = yield* SES.GetSuppressedDestination();
100 const listSuppressed = yield* SES.ListSuppressedDestinations();
101 const unsuppress = yield* SES.DeleteSuppressedDestination();
102 const sendBounce = yield* SES.SendBounce();
103 const sendCustomVerification = yield* SES.SendCustomVerificationEmail(
104 identity,
105 configSet,
106 );
107 // Scoped by REFERENCE to the address the gated test verifies. The binding
108 // above is scoped to the fixture's own (unverifiable) domain, so it can
109 // only ever authorize verification of addresses AT that domain — which is
110 // why the ungated test gets a request-level rejection there and this
111 // separate binding is needed for a real send. No ownership: the recipient
112 // is never declared as a resource.
113 const sendCustomVerificationVerified = CVE_RECIPIENT
114 ? yield* SES.SendCustomVerificationEmail({
115 emailIdentity: CVE_RECIPIENT,
116 })
117 : undefined;
118 const getMessageInsights = yield* SES.GetMessageInsights();
119 const batchGetMetricData = yield* SES.BatchGetMetricData();
120 const getDomainStatisticsReport = yield* SES.GetDomainStatisticsReport();
121 const getBlacklistReports = yield* SES.GetBlacklistReports();
122 const TemplateName = yield* template.templateName;
123
124 return {
125 fetch: Effect.gen(function* () {
126 const request = yield* HttpServerRequest;
127 const url = new URL(request.originalUrl);
128 const pathname = url.pathname;
129 const from = url.searchParams.get("from") ?? undefined;
130 const to = url.searchParams.get("to") ?? SIMULATOR;
131 const email = url.searchParams.get("email") ?? undefined;
132
133 const respond = <A extends object, E extends { _tag: string }>(
134 effect: Effect.Effect<A, E>,
135 pick: (result: A) => object,
136 ) =>
137 effect.pipe(
138 Effect.flatMap((result) => HttpServerResponse.json(pick(result))),
139 Effect.catch((e) =>
140 HttpServerResponse.json({
141 error: e._tag,
142 message:
143 "message" in e
144 ? (e as { message?: string }).message
145 : undefined,
146 }),
147 ),
148 );
149
150 if (request.method === "POST" && pathname === "/send-simple") {
151 return yield* respond(
152 sendEmail({
153 FromEmailAddress: from,
154 Destination: { ToAddresses: [to] },
155 Content: {
156 Simple: {
157 Subject: { Data: "alchemy SES binding test" },
158 Body: { Text: { Data: "Hello from the SendEmail binding." } },
159 },
160 },
161 }),
162 (result) => ({ messageId: result.MessageId }),
163 );
164 }
165
166 if (request.method === "POST" && pathname === "/send-template") {
167 const templateName = yield* TemplateName;
168 return yield* respond(
169 sendEmail({
170 FromEmailAddress: from,
171 Destination: { ToAddresses: [to] },
172 Content: {
173 Template: {
174 TemplateName: templateName,
175 TemplateData: JSON.stringify({ name: "Ada" }),
176 },
177 },
178 }),
179 (result) => ({ messageId: result.MessageId }),
180 );
181 }
182
183 if (request.method === "POST" && pathname === "/send-plain") {
184 return yield* respond(
185 sendWithoutConfigSet({
186 FromEmailAddress: from,
187 Destination: { ToAddresses: [to] },
188 Content: {
189 Simple: {
190 Subject: { Data: "alchemy SES binding test (no config set)" },
191 Body: { Text: { Data: "Hello without a config set." } },
192 },
193 },
194 }),
195 (result) => ({ messageId: result.MessageId }),
196 );
197 }
198
199 if (request.method === "POST" && pathname === "/send-bulk") {
200 const templateName = yield* TemplateName;
201 return yield* respond(
202 sendBulkEmail({
203 FromEmailAddress: from,
204 DefaultContent: {
205 Template: {
206 TemplateName: templateName,
207 TemplateData: JSON.stringify({ name: "friend" }),
208 },
209 },
210 BulkEmailEntries: [
211 {
212 Destination: { ToAddresses: [to] },
213 ReplacementEmailContent: {
214 ReplacementTemplate: {
215 ReplacementTemplateData: JSON.stringify({ name: "Ada" }),
216 },
217 },
218 },
219 ],
220 }),
221 (result) => ({
222 results: (result.BulkEmailEntryResults ?? []).map((entry) => ({
223 status: entry.Status,
224 messageId: entry.MessageId,
225 error: entry.Error,
226 })),
227 }),
228 );
229 }
230
231 if (request.method === "POST" && pathname === "/render-template") {
232 return yield* respond(
233 renderTemplate({
234 TemplateData: JSON.stringify({ name: "Ada" }),
235 }),
236 (result) => ({ rendered: result.RenderedTemplate }),
237 );
238 }
239
240 if (request.method === "GET" && pathname === "/account") {
241 return yield* respond(getAccount(), (account) => ({
242 sendingEnabled: account.SendingEnabled,
243 productionAccess: account.ProductionAccessEnabled,
244 max24HourSend: account.SendQuota?.Max24HourSend,
245 }));
246 }
247
248 if (request.method === "POST" && pathname === "/suppress") {
249 return yield* respond(
250 suppress({ EmailAddress: email!, Reason: "BOUNCE" }),
251 () => ({ suppressed: email }),
252 );
253 }
254
255 if (request.method === "GET" && pathname === "/suppressed") {
256 return yield* respond(
257 getSuppressed({ EmailAddress: email! }),
258 (result) => ({
259 email: result.SuppressedDestination.EmailAddress,
260 reason: result.SuppressedDestination.Reason,
261 }),
262 );
263 }
264
265 if (request.method === "GET" && pathname === "/suppressed-list") {
266 return yield* respond(
267 listSuppressed({ Reasons: ["BOUNCE"], PageSize: 100 }),
268 (result) => ({
269 emails: (result.SuppressedDestinationSummaries ?? []).map(
270 (summary) => summary.EmailAddress,
271 ),
272 }),
273 );
274 }
275
276 if (request.method === "POST" && pathname === "/unsuppress") {
277 return yield* respond(unsuppress({ EmailAddress: email! }), () => ({
278 unsuppressed: email,
279 }));
280 }
281
282 if (request.method === "POST" && pathname === "/send-bounce") {
283 // A fabricated message id when none is supplied — SES only accepts a
284 // bounce for a message it actually received within 24h, so the
285 // ungated test drives this path and asserts a typed rejection. Set
286 // ?messageId= to a real received message id to exercise the success
287 // path (gated behind AWS_TEST_SES_BOUNCE_MESSAGE_ID in the test).
288 const messageId =
289 url.searchParams.get("messageId") ??
290 "00000000000000000000000000000000000000000000000000-0000";
291 return yield* respond(
292 sendBounce({
293 OriginalMessageId: messageId,
294 BounceSender:
295 from ?? "mailer-daemon@ses-bindings.alchemy-test.example.com",
296 BouncedRecipientInfoList: [
297 { Recipient: to, BounceType: "DoesNotExist" },
298 ],
299 }),
300 (result) => ({ messageId: result.MessageId }),
301 );
302 }
303
304 if (
305 request.method === "POST" &&
306 pathname === "/send-custom-verification"
307 ) {
308 // The template name comes from the request: on a bare account no
309 // verified sender exists to create one with, so the ungated test
310 // passes a name that does not resolve and asserts the typed
311 // rejection. AWS_TEST_SES_CVE_TEMPLATE names a real one.
312 const templateName =
313 url.searchParams.get("template") ?? "alchemy-test-missing-template";
314 return yield* respond(
315 sendCustomVerification({
316 EmailAddress: email ?? "verify-target@simulator.amazonses.com",
317 TemplateName: templateName,
318 }),
319 (result) => ({ messageId: result.MessageId }),
320 );
321 }
322
323 if (
324 request.method === "POST" &&
325 pathname === "/send-custom-verification-verified"
326 ) {
327 if (sendCustomVerificationVerified === undefined) {
328 return yield* HttpServerResponse.json(
329 { error: "AWS_TEST_SES_CVE_RECIPIENT not set at deploy time" },
330 { status: 412 },
331 );
332 }
333 const templateName =
334 url.searchParams.get("template") ?? "alchemy-test-missing-template";
335 return yield* respond(
336 sendCustomVerificationVerified({
337 EmailAddress: email ?? SIMULATOR,
338 TemplateName: templateName,
339 }),
340 (result) => ({ messageId: result.MessageId }),
341 );
342 }
343
344 if (request.method === "GET" && pathname === "/message-insights") {
345 // No real send backs a fabricated message id, so SES rejects it
346 // with a typed error (NotFoundException, or BadRequestException when
347 // VDM is disabled). Set ?messageId= to a real send's id to exercise
348 // the success path.
349 const messageId =
350 url.searchParams.get("messageId") ??
351 "0000000000000000-00000000-0000-0000-0000-000000000000-000000";
352 return yield* respond(
353 getMessageInsights({ MessageId: messageId }),
354 (result) => ({
355 messageId: result.MessageId,
356 insights: result.Insights?.length ?? 0,
357 }),
358 );
359 }
360
361 if (request.method === "POST" && pathname === "/metric-data") {
362 // SES rejects partial-day windows: "To get daily aggregated data
363 // you must not specify partial-day timestamps. Please make your
364 // interval go from midnight to midnight UTC."
365 //
366 // ?partialDay=1 deliberately sends such a window so a test can pin
367 // the typed rejection independently of whether VDM is enabled.
368 const partialDay = url.searchParams.get("partialDay") !== null;
369 const end = new Date();
370 if (!partialDay) end.setUTCHours(0, 0, 0, 0);
371 const start = new Date(end.getTime() - 7 * 24 * 3600 * 1000);
372 return yield* respond(
373 batchGetMetricData({
374 Queries: [
375 {
376 Id: "sends",
377 Namespace: "VDM",
378 Metric: "SEND",
379 StartDate: start,
380 EndDate: end,
381 },
382 ],
383 }),
384 (result) => ({ results: (result.Results ?? []).length }),
385 );
386 }
387
388 if (request.method === "GET" && pathname === "/domain-statistics") {
389 const domain =
390 url.searchParams.get("domain") ??
391 "ses-bindings.alchemy-test.example.com";
392 const end = new Date();
393 const start = new Date(end.getTime() - 7 * 24 * 3600 * 1000);
394 return yield* respond(
395 getDomainStatisticsReport({
396 Domain: domain,
397 StartDate: start,
398 EndDate: end,
399 }),
400 (result) => ({ days: result.DailyVolumes.length }),
401 );
402 }
403
404 if (request.method === "GET" && pathname === "/blacklist-reports") {
405 const ip = url.searchParams.get("ip") ?? "192.0.2.1";
406 return yield* respond(
407 getBlacklistReports({ BlacklistItemNames: [ip] }),
408 (result) => ({ ips: Object.keys(result.BlacklistReport) }),
409 );
410 }
411
412 if (request.method === "GET" && pathname === "/health") {
413 return HttpServerResponse.text("ok");
414 }
415
416 return yield* HttpServerResponse.json(
417 { error: "Not found", method: request.method, pathname },
418 { status: 404 },
419 );
420 }).pipe(Effect.orDie),
421 };
422 }).pipe(
423 Effect.provide(
424 Layer.mergeAll(
425 Lambda.EventSource,
426 SES.SendEmailHttp,
427 SES.SendBulkEmailHttp,
428 SES.RenderEmailTemplateHttp,
429 SES.GetAccountHttp,
430 SES.PutSuppressedDestinationHttp,
431 SES.GetSuppressedDestinationHttp,
432 SES.ListSuppressedDestinationsHttp,
433 SES.DeleteSuppressedDestinationHttp,
434 SES.SendBounceHttp,
435 SES.SendCustomVerificationEmailHttp,
436 SES.GetMessageInsightsHttp,
437 SES.BatchGetMetricDataHttp,
438 SES.GetDomainStatisticsReportHttp,
439 SES.GetBlacklistReportsHttp,
440 ),
441 ),
442 ),
443);