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 000000000..b86b5b771 Binary files /dev/null and b/eventbridge-scheduler-ses-abandoned-cart-notification/Architecture.png differ diff --git a/eventbridge-scheduler-ses-abandoned-cart-notification/README.md b/eventbridge-scheduler-ses-abandoned-cart-notification/README.md new file mode 100644 index 000000000..d29e22516 --- /dev/null +++ b/eventbridge-scheduler-ses-abandoned-cart-notification/README.md @@ -0,0 +1,304 @@ +# Amazon EventBridge Scheduler to AWS Lambda to Amazon SES + +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. + +Learn more about this pattern at Serverless Land Patterns: https://serverlessland.com/patterns/eventbridge-scheduler-ses-abandoned-cart-notification + +Important: this application uses various AWS services and there are costs associated with these services after the Free Tier usage - please see the [AWS Pricing page](https://aws.amazon.com/pricing/) for details. You are responsible for any AWS costs incurred. No warranty is implied in this example. + +## Requirements + +* [Create an AWS account](https://portal.aws.amazon.com/gp/aws/developer/registration/index.html) if you do not already have one and log in. The IAM user that you use must have sufficient permissions to make necessary AWS service calls and manage AWS resources. +* [AWS CLI](https://docs.aws.amazon.com/cli/latest/userguide/install-cliv2.html) installed and configured +* [Git Installed](https://git-scm.com/book/en/v2/Getting-Started-Installing-Git) +* [Terraform](https://developer.hashicorp.com/terraform/install) (>= 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 000000000..230b6bc6f --- /dev/null +++ b/eventbridge-scheduler-ses-abandoned-cart-notification/example-pattern.json @@ -0,0 +1,58 @@ +{ + "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": "300", + "framework": "Terraform", + "introBox": { + "headline": "How it works", + "text": [ + "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/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": "Verify Sender Email Address", + "link": "https://console.aws.amazon.com/ses/home#/verified-identities" + }, + { + "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": [ + "terraform init", + "terraform apply" + ] + }, + "testing": { + "text": [ + "See the GitHub repo for detailed testing instructions." + ] + }, + "cleanup": { + "text": [ + "terraform destroy", + "terraform show" + ] + }, + "authors": [ + { + "name": "Rajil Paloth", + "image": "https://i.ibb.co/r2TsqGf6/Passport-size.jpg", + "bio": "ProServe Delivery Consultant at AWS", + "linkedin": "paloth" + } + ] +} 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 000000000..d26462f2c --- /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 000000000..1ab1fa348 --- /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""" + + + + + +
+
+

Don't forget your items!

+
+
+

Hi {customer_name},

+

We noticed you left some great items in your cart on + {abandoned_at}. They're still waiting + for you!

+ + {cart_items_html} + +

Cart Total: ${cart_total}

+ +

Complete your purchase before these items sell out.

+ + + Return to Your Cart + + +

+ If you've already completed your purchase, please + disregard this email.

+
+ +
+ + + """ + + 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