AWS Fundamentals LogoAWS Fundamentals
Back to Blog

Handling Bounces and Complaints at Amazon SES

Tobias Schmidt
by Tobias Schmidt
Handling Bounces and Complaints at Amazon SES

Amazon is very strict on rules regarding its email service SES. If you’re having too many bounces or complaints, resulting in a non-healthy sending status, you’ll receive a service block easily.

That’s why you should take care of putting bounced addresses on a blocking list.

Building A Simple Serverless Solution powered by Lambda

As a target scenario, we want to have addresses for which emails have bounced or resulted in complaints in a DynamoDB table, so we check that an address is not already on our blocked list before sending them out.

Let’s do this step by step for handling bounces. As a precondition, I’m assuming that the DynamoDB table for saving your blocked addresses already exists.

Creating Queues at SQS

We’ll need two resources of aws_sqs_queue, one for receiving events from SNS and one for helping us debug if messages can’t be processed by our later-created lambda functions.

resource "aws_sqs_queue" "bounces" {
  provider                  = aws.eu-west-1
  name                      = "ses-bounces"
  message_retention_seconds = 1209600
  redrive_policy            = jsonencode({
    "deadLetterTargetArn": aws_sqs_queue.bounces_dlq.arn,
    "maxReceiveCount": 4
  })
}

resource "aws_sqs_queue" "bounces_dlq" {
  provider = aws.eu-west-1
  name     = "ses-bounces-dlq"
}

Create a Topic at SNS and Connect It to Our SQS Queue

Next, we want to have our alerting topic, which we’ll directly attach to our previously created queue with aws_sns_topic_description.

resource "aws_sns_topic" "bounces" {
  provider = aws.eu-west-1
  name     = "ses-bounces"
}

resource "aws_sns_topic_subscription" "ses_bounces_subscription" {
  provider  = aws.eu-west-1
  topic_arn = aws_sns_topic.bounces.arn
  protocol  = "sqs"
  endpoint  = aws_sqs_queue.bounces.arn
}

Setting up the Notifications to SNS for Bounces

We need to configure that SES forwards bounces which are received as messages to our SNS topic.

resource "aws_ses_identity_notification_topic" "bounces" {
  provider                = aws.eu-west-1
  topic_arn              = aws_sns_topic.bounces.arn
  notification_type  = "Bounce"
  identity                 = local.domain
  include_original_headers = *true
*}

Adding Needed IAM Roles and Policies

Firstly, we need to allow SNS to queue messages in our queue. This can be done by adding a policy via aws_sqs_queue_policy.

data "aws_iam_policy_document" "bounces" {
  policy_id = "SESBouncesQueueTopic"
  statement {
    sid       = "SESBouncesQueueTopic"
    effect    = "Allow"
    actions   = ["SQS:SendMessage"]
    resources = [aws_sqs_queue.bounces.arn]
    principals {
      identifiers = ["`"]
      type        = "`"
    }
    condition {
      test     = "ArnEquals"
      values   = [aws_sns_topic.bounces.arn]
      variable = "aws:SourceArn"
    }
  }
}

resource "aws_sqs_queue_policy" "bounces" {
  provider  = aws.eu-west-1
  queue_url = aws_sqs_queue.bounces.id
  policy    = data.aws_iam_policy_document.bounces.json
}

Next, we need a role for our lambda function which allows access to our DynamoDB table. Also, we need to allow SQS to invoke our function.

resource "aws_iam_role" "bounce_lambda" {
  provider           = aws.eu-west-1
  name               = "SESBouncesLambdaRole"
  assume_role_policy = data.aws_iam_policy_document.lambda_bounces.json
}

data "aws_iam_policy_document" "lambda_bounce_dynamodb" {
  statement {
    actions   = ["dynamodb:*"]
    resources = [
      "arn:aws:dynamodb:*:*:table/${local.blocked_table_name}",
    ]
  }
}

resource "aws_iam_policy" "lambda_bounce_dynamodb" {
  provider = aws.eu-west-1
  policy   = data.aws_iam_policy_document.lambda_bounce_dynamodb.json
}

resource "aws_iam_role_policy_attachment" "lambda_bounce_dynamodb" {
  role       = aws_iam_role.bounce_complaint_lambda.name
  policy_arn = aws_iam_policy.lambda_bounce_dynamodb.arn
}

data "aws_iam_policy_document" "lambda_bounces" {
  statement {
    actions = ["sts:AssumeRole"]
    principals {
      type       = "Service"
      identifiers = ["lambda.amazonaws.com"]
    }
  }
}

resource "aws_iam_role_policy_attachment" "lambda_sqs_role_policy" {
  role       = aws_iam_role.bounce_lambda.name
  policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaSQSQueueExecutionRole"
}

Creating a Lambda Function and Adding the Event Source Mapping for Our SQS Queue

The last step for our infrastructure code is to add our lambda function, which we’ll add to the same folder inside bounce-complaint-handler.js.

data "archive_file" "lambda" {
  type        = "zip"
  source_file  = "bounce-handler.js"
  output_path = "bounce-handler.zip"
}

resource "aws_lambda_function" "bounces" {
  provider         = aws.eu-west-1
  function_name    = "ses-bounce-handler"
  description      = "Handling bounces for messages sent via SES"
  role             = aws_iam_role.bounce_lambda.arn
  handler          = "bounce-handler.handler"
  filename          = data.archive_file.lambda.output_path
  source_code_hash = filebase64sha256(data.archive_file.lambda.output_path)
  runtime          = "nodejs12.x"

  environment {
    variables = {
      BLOCKED_TABLE_NAME = *local*.preview_blocked_table_name
    }
  }
}

*resource* "aws_lambda_event_source_mapping" "bounce_mapping" {
  provider         = aws.eu-west-1
  event_source_arn = aws_sqs_queue.bounces.arn
  function_name    = aws_lambda_function.bounces.arn
}

Creating the Lambda Handler Function

The final step is to add our lambda code. As the AWS SDK is provided by default, you don’t need to include it in your packaged zip file. SQS will invoke our lambda function with a list of records, so it can be the case that we receive multiple messages with a single invocation.

const AWS = require('aws-sdk')

const ddb = *new* AWS.DynamoDB({ region: 'eu-central-1' })

async function addToBlockedList(recipients) {
    const putRequests = recipients.map(br => ({
        PutRequest: { Item: { value: { S: br } } }
    }))
    *const* RequestItems = {
    RequestItems[process.env.BLOCKED_TABLE_NAME] = putRequests
    *await* ddb.batchWriteItem({ RequestItems }).promise()
        .catch(e => console.error(e))
}

async function handleBounce(bounce) {
    const bounceType = bounce.bounceType
    const bouncedRecipients = bounce.bouncedRecipients.map(r => r.emailAddress)
    console.log(`Adding bounced recipients to block list [bounceType=${bounceType}, recipients=${bouncedRecipients.join(',')}]`)
    await addToBlockedList(bouncedRecipients)
}

exports.handler = async (event, context) => {
    const records = event.Records
    console.log(`Received records from SQS [records=${records.length}]`)
    await Promise.all(records.map(record => {
        console.log(`Working on record [messageId=${record.messageId}]`)
        const body = JSON.parse(record.body)
        const message = JSON.parse(body.Message)
        switch (message.notificationType) {
            case 'Bounce': return handleBounce(message.bounce)
            case 'Complaint': // handle your complaints!
            default*: console.warn(`Unhandled type [type=${message.notificationType}]`)
        }
        console.log(`Finished [messageId=${record.messageId}]`)
    }))
}
AWS Lambda Infographic

AWS Lambda on One Page (No Fluff)

Skip the 300-page docs. Our Lambda cheat sheet covers everything from cold starts to concurrency limits - the stuff we actually use daily.

HD quality, print-friendly. Stick it next to your desk.

Privacy Policy
By entering your email, you are opting in for our twice-a-month AWS newsletter. Once in a while, we'll promote our paid products. We'll never send you spam or sell your data.

Testing

AWS allows you to test this easily by simulating bounces. Just go to Amazon SES > Verified Identities > $YOUR_DOMAIN > Send test email. If everything works as expected, you should see bounce@simulator.amazonses.com in your DynamoDB table.

That’s it. Now you can always check if an address is already on your blocked list before sending out another one.

Conclusion

In conclusion, it is important to handle bounces and complaints properly when using AWS SES to avoid being blocked by the service.

Building a simple serverless solution powered by Lambda can help with this by creating a process for putting bounced addresses on a blocking list. This involves creating queues at SQS, connecting a topic at SNS to the queue, setting up notifications for bounces, adding needed IAM roles and policies, creating a Lambda function, and testing the solution.

By following these steps, you can ensure that your email-sending status remains healthy and that your messages are delivered successfully.

If you found this article on handling bounces and complaints at AWS SES useful, you might also enjoy these related articles:

Related Posts

Discover similar content using semantic vector search powered by AWS S3 Vectors. These posts share conceptual similarities based on machine learning embeddings.

vector_search.sh
~/blog/related-posts
$ aws s3-vectors query --embedding-model titan --index bedrock-kb-default --similarity cosine --top-k 3
✓ Found 3 semantic matches:
[MATCH_1]
cosine_similarity:0.581114
vector_dim: 1536 | euclidean_dist: 0.838
Learn How to Automate Welcome Emails with Amazon SES, Lambda and DynamoDB Streams
19b95f3a-5...
Learn How to Automate Welcome Emails with Amazon SES, Lambda and DynamoDB Streams
aws_service:lambdatimestamp:2022-08-24
slug: /blog/learn-how-to-automate-welcome-emails-with-amazon-ses-lambda-and-dynamodb-streamsembedding_match: 58.11%
[MATCH_2]
cosine_similarity:0.506019
vector_dim: 1536 | euclidean_dist: 0.988
Render - Modern Cloud without the Ops and Complexity
cover.webp...
Render - Modern Cloud without the Ops and Complexity
aws_service:lambdatimestamp:2025-07-07
slug: /blog/render-modern-cloud-without-the-opsembedding_match: 50.60%
[MATCH_3]
cosine_similarity:0.673471
vector_dim: 1536 | euclidean_dist: 0.653
AWS FinOps - Real-Time Cost Monitoring with CloudTrail and EventBridge
cover.webp...
AWS FinOps - Real-Time Cost Monitoring with CloudTrail and EventBridge
aws_service:lambdatimestamp:2025-07-04
slug: /blog/aws-finops-realtime-monitoringembedding_match: 67.35%
Query executed in ~18ms
Powered by AWS S3 Vectors
$ _
AWS Lambda Infographic

AWS Lambda on One Page (No Fluff)

Skip the 300-page docs. Our Lambda cheat sheet covers everything from cold starts to concurrency limits - the stuff we actually use daily.

HD quality, print-friendly. Stick it next to your desk.

Privacy Policy
By entering your email, you are opting in for our twice-a-month AWS newsletter. Once in a while, we'll promote our paid products. We'll never send you spam or sell your data.