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
+
+
+
+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"""
+
+
+
+
+
+
+
+
+
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"""
+
+
+ | Item | Price |
+
+
+ {rows}
+
+
+ """
+
+
+# ──────────────────────────────────────────
+# 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