Handling Bounces & Complaints at AWS SES

Handling Bounces & Complaints at AWS SES

How to Keep Your Sending Reputation High

Tobias Schmidt's photo
Tobias Schmidt
·Sep 7, 2022·

4 min read

Featured on Hashnode

Table of contents

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.

A Simple Solution via DynamoDB, SQS, SNS & 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 & 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 & 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 in 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 your Lambda Handler

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}]`)  
    }))  
}

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.

 
Share this