From 19a5ff5dc61a4fd031c53e113c64a085ac06f544 Mon Sep 17 00:00:00 2001 From: Rajil Paloth Date: Tue, 17 Mar 2026 13:04:21 +0530 Subject: [PATCH 1/2] New serverless pattern - eventbridge-scheduler-ses-abandoned-cart-notification --- .../Architecture.png | Bin 0 -> 40840 bytes .../README.md | 304 +++++++++++++ .../example-pattern.json | 59 +++ .../main.tf | 409 ++++++++++++++++++ .../notification_processor.py | 260 +++++++++++ 5 files changed, 1032 insertions(+) create mode 100644 eventbridge-scheduler-ses-abandoned-cart-notification/Architecture.png create mode 100644 eventbridge-scheduler-ses-abandoned-cart-notification/README.md create mode 100644 eventbridge-scheduler-ses-abandoned-cart-notification/example-pattern.json create mode 100644 eventbridge-scheduler-ses-abandoned-cart-notification/main.tf create mode 100644 eventbridge-scheduler-ses-abandoned-cart-notification/notification_processor.py diff --git a/eventbridge-scheduler-ses-abandoned-cart-notification/Architecture.png b/eventbridge-scheduler-ses-abandoned-cart-notification/Architecture.png new file mode 100644 index 0000000000000000000000000000000000000000..b86b5b771ac8229a25ae86b584f8fa42a66dc220 GIT binary patch literal 40840 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 1.0) installed + +### Prerequisites — Amazon SES Identity + +This pattern sends emails through Amazon SES. You **must** have a verified SES identity (email address or domain) before deploying. + +1. **Verify a sender email address** (or domain) in the [SES console](https://console.aws.amazon.com/ses/home#/verified-identities) or via the CLI: + + ```bash + aws ses verify-email-identity --email-address noreply@yourdomain.com + ``` + +2. **Check your inbox** and click the verification link sent by AWS. + +3. **If your SES account is in sandbox mode** (the default for new accounts), you must also verify every **recipient** email address and ensure `ses:SendEmail` permissions include the recipient identity. For the seed test data included in this pattern: + + ```bash + aws ses verify-email-identity --email-address rajilpaloth@gmail.com + ``` + + > **Note:** In sandbox mode, SES requires `ses:SendEmail` permission for both the sender and recipient identities. If your SES account has **production access** enabled, recipient verification and permissions are not required. + +4. **Note your SES identity ARN** — you will need it during deployment: + + ``` + arn:aws:ses:{region}:{account-id}:identity/noreply@yourdomain.com + ``` + + You can retrieve it with: + + ```bash + echo "arn:aws:ses:$(aws configure get region):$(aws sts get-caller-identity --query Account --output text):identity/noreply@yourdomain.com" + ``` + +5. Confirm both identities show `"VerificationStatus": "Success"`: + + ```bash + aws ses get-identity-verification-attributes \ + --identities noreply@yourdomain.com rajilpaloth@gmail.com + ``` + +### Prerequisites — DynamoDB Table Population + +This pattern deploys the DynamoDB table and seeds it with three test records for demonstration purposes. In a production environment, you will need a **separate system or mechanism** to populate the DynamoDB table with real customer data whenever a cart is abandoned. Common approaches include: + +- An **API Gateway + Lambda** endpoint called by your e-commerce application when a cart is abandoned +- A **DynamoDB Streams** consumer that reacts to cart updates and sets the `CartAbandoned` flag +- A **Step Functions** workflow that monitors cart activity and marks carts as abandoned after a timeout period +- A direct **SDK write** from your application backend + +The notification processor Lambda in this pattern only **reads** the table and **updates** the `NotificationSent` flag — it does not create or manage cart records. + +**DynamoDB record schema expected by the Lambda function:** + +| Attribute | Type | Description | +|---|---|---| +| `CustomerId` | String (Hash Key) | Unique customer identifier | +| `Email` | String | Customer email address | +| `CustomerName` | String | Customer display name | +| `CartAbandoned` | String (`"true"` / `"false"`) | Whether the cart is abandoned (GSI hash key) | +| `NotificationSent` | String (`"true"` / `"false"`) | Whether the notification email has been sent | +| `CartItems` | List of Maps | Items in the cart (`ItemName`, `Price`) | +| `CartTotal` | Number | Total cart value | +| `CartAbandonedAt` | String (ISO 8601) | Timestamp when the cart was abandoned | + +## Architecture + +![Architecture diagram](Architecture.png) + +EventBridge Scheduler (rate 1 hour) +│ +├── on failure ──▶ SQS DLQ +│ +▼ +Notification Processor Lambda +│ +├── READ ──▶ DynamoDB (abandoned-carts) +│ query CartAbandoned = "true" +│ filter NotificationSent = "false" +│ +└── SEND ──▶ Amazon SES +per-customer abandoned cart email +then mark NotificationSent = "true" + +## Deployment Instructions + +1. Create a new directory, navigate to that directory in a terminal and clone the GitHub repository: + + ```bash + git clone https://github.com/aws-samples/serverless-patterns + ``` + +1. Change directory to the pattern directory: + + ```bash + cd serverless-patterns/eventbridge-scheduler-ses-abandoned-cart-notification + ``` + +1. Initialize Terraform: + + ```bash + terraform init + ``` + +1. Review the execution plan: + + ```bash + terraform plan \ + -var="aws_region=us-east-1" \ + -var="prefix=cartnotify" \ + -var="ses_identity_arn=arn:aws:ses:us-east-1:123456789012:identity/noreply@yourdomain.com" \ + -var="sender_email=noreply@yourdomain.com" + ``` + + Replace the SES identity ARN, sender email, and region with your actual values. + +1. Deploy the resources: + + ```bash + terraform apply \ + -var="aws_region=us-east-1" \ + -var="prefix=cartnotify" \ + -var="ses_identity_arn=arn:aws:ses:us-east-1:123456789012:identity/noreply@yourdomain.com" \ + -var="sender_email=noreply@yourdomain.com" + ``` + + Type `yes` when prompted. Deployment takes approximately 1–2 minutes. + +1. Note the outputs from the Terraform deployment process. These contain the resource names and ARNs used for testing: + + ```bash + terraform output + ``` + +## How it works + +1. **EventBridge Scheduler** fires on the configured schedule (default: every hour) and invokes the **Notification Processor Lambda** function. + +2. The Lambda function queries the **DynamoDB Global Secondary Index** (`CartAbandonedIndex`) to retrieve all records where `CartAbandoned = "true"`. + +3. For each abandoned cart record, the function checks the `NotificationSent` attribute: + - If `"true"` → the customer has already been emailed, so the record is **skipped**. + - If `"false"` → the function proceeds to send an email. + +4. The function builds a **personalised HTML email** containing the customer's name, cart items, cart total, and a call-to-action button, then sends it via **Amazon SES**. + +5. After a successful send, the function **updates the DynamoDB record**, setting `NotificationSent = "true"` and recording a `NotifiedAt` timestamp. This ensures the customer is **never emailed twice** for the same abandoned cart, even if the scheduler fires again. + +6. If the scheduler fails to invoke the Lambda after 3 retries, the event is sent to the **SQS Dead-Letter Queue** for investigation. + +**Seed test data included:** + +| CustomerId | Email | CartAbandoned | NotificationSent | Expected Behaviour | +|---|---|---|---|---| +| `cust-001` | `rajilpaloth@gmail.com` | `true` | `false` | ✅ Will receive email | +| `cust-002` | `activecustomer@example.com` | `false` | `false` | ⏭️ Not abandoned — not queried | +| `cust-003` | `alreadynotified@example.com` | `true` | `true` | ⏭️ Already notified — skipped | + +## Testing + +1. **Invoke the Lambda function manually** (without waiting for the schedule): + + ```bash + aws lambda invoke \ + --function-name cartnotify-notification-processor \ + --cli-binary-format raw-in-base64-out \ + --payload '{ + "source": "manual-test", + "taskType": "abandoned-cart-notification" + }' \ + /dev/stdout 2>/dev/null | jq . + ``` + + Expected response: + + ```json + { + "statusCode": 200, + "body": "{\"invokedAt\": \"2025-01-15T12:00:05Z\", \"notificationsSent\": 1, \"skipped\": 1, \"errors\": 0}" + } + ``` + +2. **Check the recipient inbox** (`rajilpaloth@gmail.com`) for the abandoned cart email. Check the spam/junk folder if it does not appear in the inbox. + +3. **Verify the DynamoDB record was updated:** + + ```bash + aws dynamodb get-item \ + --table-name cartnotify-abandoned-carts \ + --key '{"CustomerId": {"S": "cust-001"}}' \ + --query "Item.{NotificationSent:NotificationSent.S,NotifiedAt:NotifiedAt.S}" \ + --output table + ``` + + Expected: + + ``` + ────────────────────────────────────────── + | GetItem | + +------------------+---------------------+ + | NotificationSent | NotifiedAt | + +------------------+---------------------+ + | true | 2025-01-15T12:00:05Z| + +------------------+---------------------+ + ``` + +4. **Verify idempotency** by invoking the Lambda again: + + ```bash + aws lambda invoke \ + --function-name cartnotify-notification-processor \ + --cli-binary-format raw-in-base64-out \ + --payload '{"source": "idempotency-test"}' \ + /dev/stdout 2>/dev/null | jq . + ``` + + Expected — no duplicate emails sent: + + ```json + { + "statusCode": 200, + "body": "{\"invokedAt\": \"...\", \"notificationsSent\": 0, \"skipped\": 2, \"errors\": 0}" + } + ``` + +5. **Reset the test record** to re-test: + + ```bash + aws dynamodb update-item \ + --table-name cartnotify-abandoned-carts \ + --key '{"CustomerId": {"S": "cust-001"}}' \ + --update-expression "SET NotificationSent = :f REMOVE NotifiedAt" \ + --expression-attribute-values '{":f": {"S": "false"}}' + ``` + +6. **Check Lambda logs** for detailed execution information: + + ```bash + aws logs tail /aws/lambda/cartnotify-notification-processor --follow + ``` + +7. **Check the DLQ** for any failed scheduler invocations: + + ```bash + aws sqs get-queue-attributes \ + --queue-url $(terraform output -raw dlq_queue_url) \ + --attribute-names ApproximateNumberOfMessages + ``` + +## Troubleshooting + +| Symptom | Cause | Fix | +|---|---|---| +| `MessageRejected: Email address is not verified` | SES sandbox — recipient not verified | Run `aws ses verify-email-identity --email-address rajilpaloth@gmail.com` and click the verification link | +| `AccessDenied` on `ses:SendEmail` | SES identity ARN mismatch | Verify the `ses_identity_arn` variable matches your sender email exactly | +| Lambda returns `notificationsSent: 0` | All abandoned carts already notified | Reset with the `update-item` command in the Testing section | +| No items returned from GSI query | GSI not yet backfilled | Wait 30 seconds after deploy for the GSI to populate | +| Schedule never fires | Schedule expression typo | Run `aws scheduler get-schedule --name cartnotify-abandoned-cart-notify` | +| DLQ filling up | Lambda timeout or SES errors | Check CloudWatch logs and increase the Lambda timeout if needed | + +## Cleanup + +1. Delete the stack: + + ```bash + terraform destroy \ + -var="aws_region=us-east-1" \ + -var="prefix=cartnotify" \ + -var="ses_identity_arn=arn:aws:ses:us-east-1:123456789012:identity/noreply@yourdomain.com" \ + -var="sender_email=noreply@yourdomain.com" \ + -auto-approve + ``` + +2. Confirm all resources have been removed: + + ```bash + aws dynamodb describe-table --table-name cartnotify-abandoned-carts 2>&1 | grep -q "ResourceNotFoundException" && echo "Table deleted" || echo "Table still exists" + aws lambda get-function --function-name cartnotify-notification-processor 2>&1 | grep -q "ResourceNotFoundException" && echo "Lambda deleted" || echo "Lambda still exists" + ``` + +3. (Optional) Remove the SES verified identities if no longer needed: + + ```bash + aws ses delete-verified-email-identity --email-address noreply@yourdomain.com + aws ses delete-verified-email-identity --email-address rajilpaloth@gmail.com + ``` + +---- +Copyright 2025 Amazon.com, Inc. or its affiliates. All Rights Reserved. + +SPDX-License-Identifier: MIT-0 \ No newline at end of file diff --git a/eventbridge-scheduler-ses-abandoned-cart-notification/example-pattern.json b/eventbridge-scheduler-ses-abandoned-cart-notification/example-pattern.json new file mode 100644 index 0000000000..8616bc19b6 --- /dev/null +++ b/eventbridge-scheduler-ses-abandoned-cart-notification/example-pattern.json @@ -0,0 +1,59 @@ +{ + "title": "Step Functions to Athena", + "description": "Create a Step Functions workflow to query Amazon Athena.", + "language": "Python", + "level": "200", + "framework": "AWS CDK", + "introBox": { + "headline": "How it works", + "text": [ + "This sample project demonstrates how to use an AWS Step Functions state machine to query Athena and get the results. This pattern is leveraging the native integration between these 2 services which means only JSON-based, structured language is used to define the implementation.", + "With Amazon Athena you can get up to 1000 results per invocation of the GetQueryResults method and this is the reason why the Step Function has a loop to get more results. The results are sent to a Map which can be configured to handle (the DoSomething state) the items in parallel or one by one by modifying the max_concurrency parameter.", + "This pattern deploys one Step Functions, two S3 Buckets, one Glue table and one Glue database." + ] + }, + "gitHub": { + "template": { + "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/sfn-athena-cdk-python", + "templateURL": "serverless-patterns/sfn-athena-cdk-python", + "projectFolder": "sfn-athena-cdk-python", + "templateFile": "sfn_athena_cdk_python_stack.py" + } + }, + "resources": { + "bullets": [ + { + "text": "Call Athena with Step Functions", + "link": "https://docs.aws.amazon.com/step-functions/latest/dg/connect-athena.html" + }, + { + "text": "Amazon Athena - Serverless Interactive Query Service", + "link": "https://aws.amazon.com/athena/" + } + ] + }, + "deploy": { + "text": [ + "sam deploy" + ] + }, + "testing": { + "text": [ + "See the GitHub repo for detailed testing instructions." + ] + }, + "cleanup": { + "text": [ + "Delete the stack: cdk delete." + ] + }, + "authors": [ + { + "name": "Your name", + "image": "link-to-your-photo.jpg", + "bio": "Your bio.", + "linkedin": "linked-in-ID", + "twitter": "twitter-handle" + } + ] +} diff --git a/eventbridge-scheduler-ses-abandoned-cart-notification/main.tf b/eventbridge-scheduler-ses-abandoned-cart-notification/main.tf new file mode 100644 index 0000000000..d26462f2c5 --- /dev/null +++ b/eventbridge-scheduler-ses-abandoned-cart-notification/main.tf @@ -0,0 +1,409 @@ +terraform { + required_version = ">= 1.0" + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.32.0" + } + } +} + +provider "aws" { + region = var.aws_region +} + +############################################################ +# Variables +############################################################ + +variable "aws_region" { + description = "AWS region for resources (e.g. us-east-1, us-west-2)" + type = string + + validation { + condition = can(regex("^[a-z]{2}-[a-z]+-[0-9]+$", var.aws_region)) + error_message = "Must be a valid AWS region (e.g. us-east-1, eu-west-2)." + } +} + +variable "prefix" { + description = "Unique prefix for all resource names" + type = string + + validation { + condition = can(regex("^[a-z0-9][a-z0-9\\-]{1,20}$", var.prefix)) + error_message = "Prefix must be 2-21 lowercase alphanumeric characters or hyphens." + } +} + +variable "ses_identity_arn" { + description = "ARN of the verified SES identity (email or domain) used as the sender (e.g. arn:aws:ses:us-east-1:123456789012:identity/noreply@example.com)" + type = string + + validation { + condition = can(regex("^arn:aws:ses:[a-z0-9-]+:[0-9]{12}:identity/.+$", var.ses_identity_arn)) + error_message = "Must be a valid SES identity ARN (e.g. arn:aws:ses:us-east-1:123456789012:identity/noreply@example.com)." + } +} + +variable "sender_email" { + description = "Verified SES sender email address (must match the SES identity)" + type = string + + validation { + condition = can(regex("^[^@]+@[^@]+\\.[^@]+$", var.sender_email)) + error_message = "Must be a valid email address." + } +} + +variable "schedule_expression" { + description = "EventBridge Scheduler expression (e.g. rate(1 hour), rate(5 minutes) for testing)" + type = string + default = "rate(1 hour)" +} + +variable "log_retention_days" { + description = "CloudWatch log retention in days (0 = never expire)" + type = number + default = 14 +} + +############################################################ +# Data Sources +############################################################ + +data "aws_caller_identity" "current" {} +data "aws_region" "current" {} + +############################################################ +# 1. DYNAMODB TABLE (abandoned cart records) +# +# NOTE: hash_key is deprecated in the AWS provider and will +# be replaced by a key_schema block in a future major +# version. The replacement syntax is not yet available in +# the 5.x provider, so hash_key is used here. The +# deprecation warning can be safely ignored. +############################################################ + +resource "aws_dynamodb_table" "abandoned_carts" { + name = "${var.prefix}-abandoned-carts" + billing_mode = "PAY_PER_REQUEST" + hash_key = "CustomerId" + + attribute { + name = "CustomerId" + type = "S" + } + + attribute { + name = "CartAbandoned" + type = "S" + } + + # GSI to efficiently query only abandoned carts + global_secondary_index { + name = "CartAbandonedIndex" + hash_key = "CartAbandoned" + projection_type = "ALL" + } + + tags = { + Project = "${var.prefix}-abandoned-cart-notifications" + } +} + +# ── Seed test data ── + +resource "aws_dynamodb_table_item" "test_user_abandoned" { + table_name = aws_dynamodb_table.abandoned_carts.name + hash_key = aws_dynamodb_table.abandoned_carts.hash_key + + item = jsonencode({ + CustomerId = { S = "cust-001" } + Email = { S = "rajilpaloth@gmail.com" } + CustomerName = { S = "Rajil Paloth" } + CartAbandoned = { S = "true" } + NotificationSent = { S = "false" } + CartItems = { L = [ + { M = { ItemName = { S = "Wireless Headphones" }, Price = { N = "79.99" } } }, + { M = { ItemName = { S = "Phone Case" }, Price = { N = "19.99" } } } + ] } + CartTotal = { N = "99.98" } + CartAbandonedAt = { S = "2025-01-15T08:30:00Z" } + }) +} + +resource "aws_dynamodb_table_item" "test_user_active" { + table_name = aws_dynamodb_table.abandoned_carts.name + hash_key = aws_dynamodb_table.abandoned_carts.hash_key + + item = jsonencode({ + CustomerId = { S = "cust-002" } + Email = { S = "activecustomer@example.com" } + CustomerName = { S = "Active Customer" } + CartAbandoned = { S = "false" } + NotificationSent = { S = "false" } + CartItems = { L = [ + { M = { ItemName = { S = "Laptop Stand" }, Price = { N = "49.99" } } } + ] } + CartTotal = { N = "49.99" } + CartAbandonedAt = { S = "N/A" } + }) +} + +resource "aws_dynamodb_table_item" "test_user_already_notified" { + table_name = aws_dynamodb_table.abandoned_carts.name + hash_key = aws_dynamodb_table.abandoned_carts.hash_key + + item = jsonencode({ + CustomerId = { S = "cust-003" } + Email = { S = "alreadynotified@example.com" } + CustomerName = { S = "Already Notified" } + CartAbandoned = { S = "true" } + NotificationSent = { S = "true" } + CartItems = { L = [ + { M = { ItemName = { S = "USB Cable" }, Price = { N = "12.99" } } } + ] } + CartTotal = { N = "12.99" } + CartAbandonedAt = { S = "2025-01-14T10:00:00Z" } + }) +} + +############################################################ +# 2. NOTIFICATION PROCESSOR LAMBDA +############################################################ + +resource "aws_iam_role" "processor_role" { + name = "${var.prefix}-notification-processor-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { Service = "lambda.amazonaws.com" } + }] + }) +} + +resource "aws_iam_role_policy_attachment" "processor_basic" { + role = aws_iam_role.processor_role.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole" +} + +resource "aws_iam_role_policy" "processor_dynamodb" { + name = "${var.prefix}-processor-dynamodb" + role = aws_iam_role.processor_role.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "DynamoDBReadWrite" + Effect = "Allow" + Action = [ + "dynamodb:Scan", + "dynamodb:Query", + "dynamodb:GetItem", + "dynamodb:UpdateItem" + ] + Resource = [ + aws_dynamodb_table.abandoned_carts.arn, + "${aws_dynamodb_table.abandoned_carts.arn}/index/*" + ] + }] + }) +} + +resource "aws_iam_role_policy" "processor_ses" { + name = "${var.prefix}-processor-ses" + role = aws_iam_role.processor_role.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "SendEmail" + Effect = "Allow" + Action = "ses:SendEmail" + Resource = var.ses_identity_arn + }] + }) +} + +resource "aws_cloudwatch_log_group" "processor_logs" { + name = "/aws/lambda/${var.prefix}-notification-processor" + retention_in_days = var.log_retention_days +} + +data "archive_file" "processor_zip" { + type = "zip" + source_file = "${path.module}/notification_processor.py" + output_path = "${path.module}/notification_processor.zip" +} + +resource "aws_lambda_function" "processor" { + function_name = "${var.prefix}-notification-processor" + role = aws_iam_role.processor_role.arn + handler = "notification_processor.lambda_handler" + runtime = "python3.14" + timeout = 60 + memory_size = 256 + filename = data.archive_file.processor_zip.output_path + source_code_hash = data.archive_file.processor_zip.output_base64sha256 + + environment { + variables = { + DYNAMODB_TABLE = aws_dynamodb_table.abandoned_carts.name + SES_IDENTITY_ARN = var.ses_identity_arn + SENDER_EMAIL = var.sender_email + PREFIX = var.prefix + } + } + + depends_on = [ + aws_cloudwatch_log_group.processor_logs, + aws_iam_role_policy_attachment.processor_basic, + aws_iam_role_policy.processor_dynamodb, + aws_iam_role_policy.processor_ses, + ] +} + +############################################################ +# 3. SQS DEAD-LETTER QUEUE +############################################################ + +resource "aws_sqs_queue" "scheduler_dlq" { + name = "${var.prefix}-cart-notify-dlq" + message_retention_seconds = 1209600 # 14 days +} + +############################################################ +# 4. EVENTBRIDGE SCHEDULER +############################################################ + +resource "aws_iam_role" "scheduler_role" { + name = "${var.prefix}-cart-notify-scheduler-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Action = "sts:AssumeRole" + Effect = "Allow" + Principal = { Service = "scheduler.amazonaws.com" } + }] + }) +} + +resource "aws_iam_role_policy" "scheduler_permissions" { + name = "${var.prefix}-scheduler-permissions" + role = aws_iam_role.scheduler_role.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Sid = "InvokeLambda" + Effect = "Allow" + Action = "lambda:InvokeFunction" + Resource = aws_lambda_function.processor.arn + }, + { + Sid = "SendToDLQ" + Effect = "Allow" + Action = "sqs:SendMessage" + Resource = aws_sqs_queue.scheduler_dlq.arn + } + ] + }) +} + +resource "aws_sqs_queue_policy" "allow_scheduler" { + queue_url = aws_sqs_queue.scheduler_dlq.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Sid = "AllowSchedulerSendMessage" + Effect = "Allow" + Principal = { Service = "scheduler.amazonaws.com" } + Action = "sqs:SendMessage" + Resource = aws_sqs_queue.scheduler_dlq.arn + Condition = { + ArnEquals = { + "aws:SourceArn" = "arn:aws:scheduler:${var.aws_region}:${data.aws_caller_identity.current.account_id}:schedule/default/${var.prefix}-abandoned-cart-notify" + } + } + }] + }) +} + +resource "aws_cloudwatch_log_group" "scheduler_logs" { + name = "/aws/scheduler/${var.prefix}-abandoned-cart-notify" + retention_in_days = var.log_retention_days +} + +resource "aws_scheduler_schedule" "abandoned_cart_notify" { + name = "${var.prefix}-abandoned-cart-notify" + schedule_expression = var.schedule_expression + + flexible_time_window { + mode = "OFF" + } + + target { + arn = aws_lambda_function.processor.arn + role_arn = aws_iam_role.scheduler_role.arn + + input = jsonencode({ + source = "eventbridge-scheduler" + taskType = "abandoned-cart-notification" + invokedAt = "REPLACED_AT_RUNTIME" + }) + + retry_policy { + maximum_retry_attempts = 3 + maximum_event_age_in_seconds = 3600 + } + + dead_letter_config { + arn = aws_sqs_queue.scheduler_dlq.arn + } + } + + depends_on = [aws_cloudwatch_log_group.scheduler_logs] +} + +############################################################ +# 5. OUTPUTS +############################################################ + +output "prefix" { + value = var.prefix +} + +output "schedule_name" { + value = aws_scheduler_schedule.abandoned_cart_notify.name +} + +output "schedule_arn" { + value = aws_scheduler_schedule.abandoned_cart_notify.arn +} + +output "lambda_function_name" { + value = aws_lambda_function.processor.function_name +} + +output "dynamodb_table_name" { + value = aws_dynamodb_table.abandoned_carts.name +} + +output "dlq_queue_url" { + value = aws_sqs_queue.scheduler_dlq.url +} + +output "ses_identity_arn" { + value = var.ses_identity_arn +} + +output "sender_email" { + value = var.sender_email +} \ No newline at end of file diff --git a/eventbridge-scheduler-ses-abandoned-cart-notification/notification_processor.py b/eventbridge-scheduler-ses-abandoned-cart-notification/notification_processor.py new file mode 100644 index 0000000000..1ab1fa3481 --- /dev/null +++ b/eventbridge-scheduler-ses-abandoned-cart-notification/notification_processor.py @@ -0,0 +1,260 @@ +import boto3 +import json +import os +import logging +from datetime import datetime, timezone + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + +dynamodb = boto3.resource("dynamodb") +ses = boto3.client("ses") + +TABLE_NAME = os.environ["DYNAMODB_TABLE"] +SENDER_EMAIL = os.environ["SENDER_EMAIL"] + +table = dynamodb.Table(TABLE_NAME) + + +def lambda_handler(event, context): + """ + Notification Processor — invoked hourly by EventBridge Scheduler. + + 1. Queries the DynamoDB GSI for records where CartAbandoned = "true" + 2. Filters for NotificationSent = "false" + 3. Sends a personalised abandoned-cart email via SES + 4. Marks NotificationSent = "true" so the customer is not emailed again + """ + logger.info("Received event: %s", json.dumps(event)) + invoked_at = datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + + # ── 1. Query the GSI for all abandoned carts ── + abandoned = _get_abandoned_carts() + logger.info("Found %d abandoned cart(s) needing notification", len(abandoned)) + + if not abandoned: + return _response(invoked_at, sent=0, skipped=0, errors=0) + + sent = 0 + skipped = 0 + errors = 0 + + for record in abandoned: + customer_id = record.get("CustomerId", "unknown") + email = record.get("Email", "") + name = record.get("CustomerName", "Customer") + notification_sent = record.get("NotificationSent", "false") + + # ── 2. Skip if already notified ── + if notification_sent == "true": + logger.info("Skipping %s — already notified", customer_id) + skipped += 1 + continue + + # ── 3. Skip if no email address ── + if not email: + logger.warning("Skipping %s — no email address", customer_id) + skipped += 1 + continue + + # ── 4. Build and send the email ── + try: + cart_items = _format_cart_items(record.get("CartItems", [])) + cart_total = record.get("CartTotal", "0.00") + abandoned_at = record.get("CartAbandonedAt", "recently") + + _send_email(email, name, cart_items, cart_total, abandoned_at) + logger.info("Sent notification to %s (%s)", customer_id, email) + + # ── 5. Mark as notified ── + _mark_notified(customer_id, invoked_at) + sent += 1 + + except Exception as e: + logger.error( + "Failed to notify %s (%s): %s", customer_id, email, str(e) + ) + errors += 1 + + return _response(invoked_at, sent=sent, skipped=skipped, errors=errors) + + +# ────────────────────────────────────────── +# DynamoDB helpers +# ────────────────────────────────────────── + + +def _get_abandoned_carts() -> list: + """ + Query the GSI for all records with CartAbandoned = 'true'. + The GSI returns all abandoned carts; we filter NotificationSent + in application code for flexibility. + """ + items = [] + last_key = None + + while True: + query_params = { + "IndexName": "CartAbandonedIndex", + "KeyConditionExpression": "CartAbandoned = :abandoned", + "ExpressionAttributeValues": {":abandoned": "true"}, + } + + if last_key: + query_params["ExclusiveStartKey"] = last_key + + response = table.query(**query_params) + items.extend(response.get("Items", [])) + + last_key = response.get("LastEvaluatedKey") + if not last_key: + break + + return items + + +def _mark_notified(customer_id: str, notified_at: str) -> None: + """Set NotificationSent = 'true' and record the timestamp.""" + table.update_item( + Key={"CustomerId": customer_id}, + UpdateExpression=( + "SET NotificationSent = :sent, NotifiedAt = :ts" + ), + ExpressionAttributeValues={ + ":sent": "true", + ":ts": notified_at, + }, + ) + + +# ────────────────────────────────────────── +# SES helpers +# ────────────────────────────────────────── + + +def _send_email( + to_email: str, + customer_name: str, + cart_items_html: str, + cart_total: str, + abandoned_at: str, +) -> None: + """Send a personalised abandoned-cart email via SES.""" + + subject = f"{customer_name}, you left something in your cart!" + + html_body = f""" + + + + + + + + + """ + + text_body = ( + f"Hi {customer_name},\n\n" + f"You left items in your cart on {abandoned_at}.\n" + f"Cart Total: ${cart_total}\n\n" + f"Return to your cart: https://example.com/cart\n\n" + f"If you've already completed your purchase, please disregard." + ) + + ses.send_email( + Source=SENDER_EMAIL, + Destination={"ToAddresses": [to_email]}, + Message={ + "Subject": {"Data": subject, "Charset": "UTF-8"}, + "Body": { + "Html": {"Data": html_body, "Charset": "UTF-8"}, + "Text": {"Data": text_body, "Charset": "UTF-8"}, + }, + }, + ) + + +def _format_cart_items(cart_items: list) -> str: + """Convert DynamoDB cart items list into an HTML table.""" + if not cart_items: + return "

Your cart items are waiting for you.

" + + rows = "" + for item in cart_items: + name = item.get("ItemName", "Item") + price = item.get("Price", "0.00") + # Handle both Decimal (from DynamoDB resource) and string + rows += f"{name}${price}\n" + + return f""" + + + + + + {rows} + +
ItemPrice
+ """ + + +# ────────────────────────────────────────── +# Response helper +# ────────────────────────────────────────── + + +def _response(invoked_at: str, sent: int, skipped: int, errors: int) -> dict: + """Build a structured Lambda response.""" + summary = { + "invokedAt": invoked_at, + "notificationsSent": sent, + "skipped": skipped, + "errors": errors, + } + logger.info("Execution summary: %s", json.dumps(summary)) + return {"statusCode": 200, "body": json.dumps(summary)} \ No newline at end of file From ed8ef2c7dd964ad8b11615ff6e807a650fa020b8 Mon Sep 17 00:00:00 2001 From: Rajil Paloth Date: Tue, 17 Mar 2026 13:32:55 +0530 Subject: [PATCH 2/2] New serverless pattern - eventbridge-scheduler-ses-abandoned-cart-notification --- .../example-pattern.json | 43 +++++++++---------- 1 file changed, 21 insertions(+), 22 deletions(-) diff --git a/eventbridge-scheduler-ses-abandoned-cart-notification/example-pattern.json b/eventbridge-scheduler-ses-abandoned-cart-notification/example-pattern.json index 8616bc19b6..230b6bc6fb 100644 --- a/eventbridge-scheduler-ses-abandoned-cart-notification/example-pattern.json +++ b/eventbridge-scheduler-ses-abandoned-cart-notification/example-pattern.json @@ -1,40 +1,39 @@ { - "title": "Step Functions to Athena", - "description": "Create a Step Functions workflow to query Amazon Athena.", + "title": "EventBridge Scheduler + SES Integration- Per-customer notification scheduling (abandoned cart, billing reminders)", + "description": "Create a EventBridge scheduler which sends per customer notifcations for abandoned carts and billing reminders using SES.", "language": "Python", - "level": "200", - "framework": "AWS CDK", + "level": "300", + "framework": "Terraform", "introBox": { "headline": "How it works", "text": [ - "This sample project demonstrates how to use an AWS Step Functions state machine to query Athena and get the results. This pattern is leveraging the native integration between these 2 services which means only JSON-based, structured language is used to define the implementation.", - "With Amazon Athena you can get up to 1000 results per invocation of the GetQueryResults method and this is the reason why the Step Function has a loop to get more results. The results are sent to a Map which can be configured to handle (the DoSomething state) the items in parallel or one by one by modifying the max_concurrency parameter.", - "This pattern deploys one Step Functions, two S3 Buckets, one Glue table and one Glue database." + "This pattern demonstrates how to use Amazon EventBridge Scheduler to drive per-customer abandoned cart email notifications on an hourly cadence. A Lambda function, invoked by the scheduler, queries a DynamoDB GSI for customers with abandoned carts that have not yet been notified, sends each a personalised HTML email via Amazon SES, and marks the record as notified to prevent duplicate emails. The pattern includes idempotent notification logic, seed test data, a dead-letter queue for failed scheduler invocations, and least-privilege IAM policies scoped to the specific SES identity and DynamoDB table." ] }, "gitHub": { "template": { - "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/sfn-athena-cdk-python", - "templateURL": "serverless-patterns/sfn-athena-cdk-python", - "projectFolder": "sfn-athena-cdk-python", - "templateFile": "sfn_athena_cdk_python_stack.py" + "repoURL": "https://github.com/aws-samples/serverless-patterns/tree/main/eventbridge-scheduler-ses-abandoned-cart-notification", + "templateURL": "serverless-patterns/eventbridge-scheduler-ses-abandoned-cart-notification", + "projectFolder": "eventbridge-scheduler-ses-abandoned-cart-notification", + "templateFile": "main.tf" } }, "resources": { "bullets": [ { - "text": "Call Athena with Step Functions", - "link": "https://docs.aws.amazon.com/step-functions/latest/dg/connect-athena.html" + "text": "Verify Sender Email Address", + "link": "https://console.aws.amazon.com/ses/home#/verified-identities" }, { - "text": "Amazon Athena - Serverless Interactive Query Service", - "link": "https://aws.amazon.com/athena/" + "text": "Request production access (Moving out of the Amazon SES sandbox)", + "link": "https://docs.aws.amazon.com/ses/latest/dg/request-production-access.html" } ] }, "deploy": { "text": [ - "sam deploy" + "terraform init", + "terraform apply" ] }, "testing": { @@ -44,16 +43,16 @@ }, "cleanup": { "text": [ - "Delete the stack: cdk delete." + "terraform destroy", + "terraform show" ] }, "authors": [ { - "name": "Your name", - "image": "link-to-your-photo.jpg", - "bio": "Your bio.", - "linkedin": "linked-in-ID", - "twitter": "twitter-handle" + "name": "Rajil Paloth", + "image": "https://i.ibb.co/r2TsqGf6/Passport-size.jpg", + "bio": "ProServe Delivery Consultant at AWS", + "linkedin": "paloth" } ] }