Email Delivery Status Monitoring with Amazon SES and SNS in Laravel

Share this

Amazon Simple Email Service (SES) not only sends emails but also receives email statuses such as sent, delivered, or read. Amazon SES can only receive the status of emails it delivers, but it can’t send these statuses to your application. To track them, you need to use Amazon Simple Notification Service (SNS). Amazon SES assigns a unique message ID to each email that it successfully submits to send. When Amazon SES receives a bounce or complaint message from an ISP, it forwards the feedback message to you. The format of bounce and complaint messages varies between ISPs, but Amazon SES interprets these messages and, if you choose to set up Amazon SNS topics for them, categorizes them into JSON objects.

Image from Larvel SES

Process to handle.

To implement, we will use the following. 

1. Create SNS Topic

2. Create subscription

3. Create Configuration Set

4. Confirm subscription

Create an SNS topic with standard configuration. 

Create SNS Topic

After creating a topic, go to the subscription panel and select the topic you created. Then select the HTTP protocol and set your site URL as the endpoint.

Create Subscription

Each Subscription needs to be confirmed, after creation they are in a PendingConfirmation state.To confirm our subscription, we need to implement the endpoints in our backend, and call Request confirmations from the SNS dashboard.

For this we will create an API which handles the request and a controller to process responses.

Furthermore created configuration set from SES dashboard and link to your created SNS topic. Using a configuration set, you’ll be able to determine what information about the emails SES will be sending to the topic.

Create Configuration Set from SES dashboard.

Once the configuration set is added, you’ll see it in the list.You need to click on the name of 

the configuration set to open the Edit Configuration Set tab with configurations. Then, click Select a destination type. 

Select Event type form Configuration Set Event Destination

Specify destination form Configuration Set Event Destination

Create Event Destination

Creating webhook endpoint

Lets create an api which handles the request and a controller to process the response.

Route::post('handle-notifications', 'SesController@handleNotification');
Route::post('test-email', 'SesController@testEmail');

CSRF error – When the request comes from outside of the Laravel, it will throw an unauthenticated error like the CSRF token is missing.You have to edit the except variable with the below code to exclude the SNS route from the CSRF Verification.For such cases lets edit VerifyCsrfToken.php

/**
* The URIs that should be excluded from CSRF verification.
*
* @var array
*/
protected $except = [
  'api/v1/handle-notifications',
];

Also Lets add a custom log file ses.log where it stores logs only related to our current SES email and SNS data.

'ses-log'=>[
  'driver'=>'single',
  'path' => storage_path('logs/ses.log'),
]

The above routes file are to handle notifications and send test email respectively.

Here handleNotification will handle both tracking emails and verification.Once you get the request, you have to verify the subscription first.

We get the following json response structure before confirmation subscription , after that our subscription will be confirmed.

{
    "Type": "SubscriptionConfirmation",
    "MessageId": "RESPONSE_MESSAGE_ID",
    "Token":"RESPONSE_TOKEN",
    "TopicArn": "RESPONSE_TOPIC_ARN",
    "Message":"RESPONSE_MESSAGE"

public function handleNotification(Request $request)
{
  if ($request->json('Type')  == 'SubscriptionConfirmation') {
      //subscription verification
      Log::channel('ses-log')->info('Subscription');
  }
  elseif ($request->json('Type') == 'Notification') {
      //track emails
  }
}

Let’s add a test method to send mail. Since we are not using our environment settings lets fetch our configuration from the database.

public function testEmail(Response $request): void
{
  Mail::raw('Email Status', function ($message) {
      $msg
          ->subject('Set')
          ->to('testEmail@email.com');
      $msg->getHeaders()->addTextHeader('x-ses-configuration-set', 'HandleNotification');
      Log::channel('ses-log')->info('test raw email');

  });
}

We must add headers while sending emails x-ses-configuration-set. Without x-ses-configuration-set webhook won’t be triggered. unique-id header is used to keep tracking emails.

$msg->getHeaders()->addTextHeader('x-ses-configuration-set', 'HandleNotification')

Here our configuration set is HandleNotification which we set earlier in SES.

But make sure you use config file or store in database not hardcode.In config/services.php you can add those.

'ses' => [
  'key' => env('AWS_ACCESS_KEY_ID'),
  'secret' => env('AWS_SECRET_ACCESS_KEY'),
  'region' => env('AWS_DEFAULT_REGION', 'us-east-1'),
  'options' => [
      'ConfigurationSetName' => env('AWS_SES_CONFIG_SET', 'HandleNotification'),
  ],
],

We can use email simulators to send different types of emails from aws also .To send test emails go to AWS SES and on Verified Identities select email to send.

Sending Test Email From AWS SES 

When emails are sent after the subscription is confirmed SNS call this method and get the following response.

[
  'Type' => 'Notification',
  'MessageId' => 'MESSAGE_ID',
  'TopicArn' => 'TOPIC ARN',
  'Subject' => 'SUBJECT',
  'Message' => '{}',
  'Timestamp' => '2022-12-05T07:45:15.461Z',
  'SignatureVersion' => '1',
  'Signature' => 'SIGNATURE',
  'SigningCertURL' => 'https://sns.us-east-1.amazonaws.com/SimpleNotificationService-56e67fcb41f6fec09b0196692625d385.pem',
  'UnsubscribeURL' => 'https://sns.us-east-1.amazonaws.com/?Action=Unsubscribe&SubscriptionArn=arn:aws:sns:us-east-1:551804445178:HandleNotification:5bb73c8c-f685-4528-9209-c5d82cfae9cf',
)
]


{
    "eventType": "Send",
    "mail": {
        "timestamp": "2022-12-05T07:45:15.150Z",
        "source": "testrepcts@gmail.com",
        "sourceArn": "arn:aws:ses:us-east-1:551804445178:identity/testrepcts@gmail.com",
        "sendingAccountId": "551804445178",
        "messageId": "01000184e13f078e-d0c20016-6498-459f-9562-add4773e87eb-000000",
        "destination": [
            "success@simulator.amazonses.com"
        ],
        "headersTruncated": false,
        "headers": [],
        "commonHeaders": {
            "from": [
                "testrepcts@gmail.com"
            ],
            "to": [
                "success@simulator.amazonses.com"
            ],
            "messageId": "01000184e13f078e-d0c20016-6498-459f-9562-add4773e87eb-000000",
            "subject": "test"
        },
        "tags": {},
    "send": {}
}
 

public function handleNotification(Request $request)
{
  if ($request->json('Type')  == 'SubscriptionConfirmation') {}
  elseif ($request->json('Type') == 'Notification') {
      Log::channel('ses-log')->info('Notification');
      $message = json_decode($request->json('Message'), true);
        try {
          switch ($message['eventType']) {
              case 'Bounce':
                  $bounce = $message['bounce'];
                  Log::channel('ses-log')->info('Bounce', $bounce);
                  break;
              case 'Complaint':
                  $complaint = $message['complaint'];
                  Log::channel('ses-log')->info('Complaint', $complaint);
                  break;
              case 'Send':
                  $send = $message['send'];
                  Log::channel('ses-log')->info('Send', $send);
                  break;
              case 'Open':
                  $open = $message['open'];
                  Log::channel('ses-log')->info('open', $open);
                  break;
              case 'Delivery':
                  $delivery = $message['delivery'];
                  Log::channel('ses-log')->info('Delivery', $delivery);
                  break;
              default:
                  Log::channel('ses-log')->info(‘Invalid’);
          }
      } catch (\Exception $e) {
          Log::info($e->getMessage());
      }
  }
  return response('OK', 200);
}

Here we have simply added a log for email by type for testing purposes but we can handle lots from it. We can further store it in the database. With the above message response we can handle various things in our system.