| 1 | import * as Lambda from "@/AWS/Lambda"; |
| 2 | import * as SES from "@/AWS/SES"; |
| 3 | import * as Output from "@/Output"; |
| 4 | import * as Duration from "effect/Duration"; |
| 5 | import * as Effect from "effect/Effect"; |
| 6 | import * as Layer from "effect/Layer"; |
| 7 | import * as Stream from "effect/Stream"; |
| 8 | import { HttpServerRequest } from "effect/http/HttpServerRequest"; |
| 9 | import * as HttpServerResponse from "effect/http/HttpServerResponse"; |
| 10 | import path from "pathe"; |
| 11 | |
| 12 | const main = path.resolve(import.meta.dirname, "handler.ts"); |
| 13 | |
| 14 | // The SES mailbox simulator accepts mail even in the sandbox. |
| 15 | const 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. |
| 25 | const CVE_RECIPIENT = process.env.AWS_TEST_SES_CVE_RECIPIENT; |
| 26 | |
| 27 | export class SESTestFunction extends Lambda.Function<Lambda.Function>()( |
| 28 | "SESTestFunction", |
| 29 | ) {} |
| 30 | |
| 31 | export 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 | ); |