Reducing postmark email bounce rate in Laravel

Email bounces

A bounce is a response from a mail server or mailbox provider telling the sender that an email wasn’t delivered. When an email bounces, it doesn’t make it to the recipient’s inbox instead, the mailbox provider returns it to the sender. Bounces can be early indicators of larger reputation issues or bad list-building practices, and if you don’t take them seriously, ISPs will take notice and might block your email altogether. This blog from postmark explains why we need to pay attention to bounce rates.

This post explains how we can reduce the email bounce rates on our Laravel application based on the Mail service provider Postmark. The same approach can be tweaked for other mail services providers too.

Can we stop the first bounce?

When an email is bounced we get to know by the response code or from a webhook from the mail service provider. So, we can not avoid the first email bounce in scenarios we do not have clues about the recipient domain or email beforehand. We only gain the information once we try to send it at least once. Applications which has regular email notifications such as reminders or any other notifications will try to send notifications to recipients which are marked as inactive (the previous emails sent to it were bounced). This will increase the bounce rate. So, the idea behind reducing the bounce rate is to avoid sending emails to inactive recipients.

Hands-on Laravel and Postmark

As described earlier, let us first record the inactive emails in our database. The following command will create a Model and Migration in laravel.

php artisan make:model InactiveEmail -m
Model created successfully.
Created Migration: ***_create_inactive_emails_table

let us add some basic database columns to the table to mark the necessary details in database/migrations/***_create_inactive_emails_table.php

...

public function up(){
    Schema::create('inactive_emails', function (Blueprint $table{
          $table->id();
          $table->string('email'); // the email recepient
          $table->string('record_type')->nullable(); // specific to postmark BOUNCED,DELIVERED,etc
          $table->json('data')->nullable(); // extra column to mark payload details
          $table->timestamps();
    });
}

...

Let's also add Id to the $guarded properties to allow mass assignments to other fields. This will be useful when we create the records. In app/Models/InactiveEmail.php

<?php
namespace App\Models;

use Illuminate\Database\Eloquent\Model;

class InactiveEmail extends Model
{
    protected $guarded = ['id'];
}

Check if inactive email before sending

We can always check and avoid each scenario where we send emails and avoid it. But it is not feasible and a good practice to implement. Laravel allows us to hook into the process of sending emails using event handlers. This technique will be useful as it is the single point of implementation and avoids code repetition. So let's create a listener first.

❯ php artisan make:listener MailSendingListener
Listener created successfully.

Let us also register this event listener in app/Providers/EventServiceProvider.php

<?php
namespace App\Providers;

...
use App\Listeners\MailSendingListener;
use Illuminate\Mail\Events\MessageSending;

class EventServiceProvider extends ServiceProvider
{
    protected $listen = [
        ...
        // message sending event and listener
        MessageSending::class => [
            MailSendingListener::class
        ]
    ];
 
  ...
}

So now we can add our logic in the handle method of our listener as shown below.

<?php
namespace App\Listeners;

use App\Models\InactiveEmail;
use Illuminate\Mail\Events\MessageSending;

class MailSendingListener
{
    ...

    /**
     * Handle the event.
     *
     * @param  object  $event
     * @return void
     */
    public function handle(MessageSending $event)
    {
        $emails = collect($event->message->getTo())->map(function ($item) {
            return $item->getAddress();
        })->toArray();


        if (InactiveEmail::whereIn('email', $emails)->exists()) {
            // we won't send the email as return value is false
            // tested with queue drivers as redis also sync 
            // we can also notify the super admins about this event and incase if they still want to send it they can make the email as active again
            return false;
        }


        return true;
    }
}

Sync and Mark Inactive emails

We have all set up and the final step is to populate the table of inactive emails.

  • Sync entries that are already marked as inactive from the mail service provider (Postmark).

Postmark exposes an API to fetch the bounced emails and we can make use of it to fetch the marked emails. Let's accomplish this task using an artisan command.

php artisan make:command FetchEmailBounces
Console command created successfully.

in app/Console/Commands/FetchEmailBounces.php

<?php
namespace App\Console\Commands;


use App\Models\InactiveEmail;
use Illuminate\Console\Command;
use Illuminate\Support\Facades\Http;


class FetchEmailBounces extends Command
{
    /**
    * The name and signature of the console command.
    *
    * @var string
    */
    protected $signature = 'fetch-email-bounces';


    /**
     * The console command description.
     *
     * @var string
     */
    protected $description = 'Fetches bounced emails from Postmark';


    /**
     * Create a new command instance.
     *
     * @return void
     */
    public function __construct()
    {
        parent::__construct();
    }


    /**
     * Execute the console command.
     *
     * @return int
     */
    public function handle()
    {
        // the postmark token
        $token = config('services.postmark.token');


        $this->info('Fetching Hardbounces');


        $offset = 0; // the offset in fetching


        do {
            $response = Http::withHeaders([
                'Accept' => 'application/json',
                'X-Postmark-Server-Token' => $token,
            ])->get(
                'https://api.postmarkapp.com/bounces',
                [
                    'type' => 'HardBounce',
                    'inactive' => 'true',
                    'count' => 500,
                    'offset' => $offset,
                ]
            )
            ->json();


            foreach ($response['Bounces'] as $data) {
                // create the entry in our DB
                InactiveEmail::updateOrCreate([
                    'email' => $data['Email'],
                    'record_type' => $data['RecordType'],
                ], [
                    'data' => $data,
                ]);
            }


            $offset++;
        } while ($response['TotalCount'] > 500);


        // Also useful to fetch spam


        $this->info('Fetching SpamComplaint');


        $offset = 0;


        do {
            $response = Http::withHeaders([
                'Accept' => 'application/json',
                'X-Postmark-Server-Token' => $token,
            ])->get(
                'https://api.postmarkapp.com/bounces',
                [
                    'type' => 'SpamComplaint',
                    'count' => 500,
                    'offset' => $offset,
                ]
            )
            ->json();


            foreach ($response['Bounces'] as $data) {
                InactiveEmail::updateOrCreate([
                    'email' => $data['Email'],
                    'record_type' => $data['RecordType'],
                ], [
                    'data' => $data,
                ]);
            }


            $offset++;
        } while ($response['TotalCount'] > 500);


        return 0;
    }
}

Now we can run php artisan fetch-email-bounces to sync all the records from the mail service provider to our DB.

  • Webhooks to mark new bounces

We can run the above command periodically as a cronjob and update our tables. But Postmark also has the option to notify us when there is an email bounce. We can make use of it to update our table. Let us add a webhook to our app to accept the payload sent by postmark

in routes/api.php

<?php

...

Route::post('/postmark/bounced',[PostmarkWebhookController::class, 'bounced']);

let us also define our controller app/Http/Controllers/PostmarkWebhookController.php

<?php
namespace App\Http\Controllers;


use Illuminate\Http\Request;
use App\Models\InactiveEmail;


class PostmarkWebhookController extends Controller
{
    public function bounced(Request $request)
    {
        $data = $request->all();

        InactiveEmail::updateOrCreate([
            'email' => $data['Email'],
            'record_type' => $data['RecordType'],
        ], [
            'data' => $data,
        ]);

        // just a 200 response
        return response()->json(['message' => 'success']);
    }
}

The endpoint is open for now. you can use simple authorization tokens to avoid unwanted traffic to this endpoint later.

Now let us set up Postmark to send their payload to this webhook.


Now Click add webhook and you can add the details as follows.


Notice you can use custom headers and basic auth. You also have the option in the postmark to test the endpoint. Finally, you can save the webhook and it will be active from there onwards.

Summary

The post explains about reducing the email bounce rate of your Laravel application integrated with Postmark. The same approach can be extended to any other mail service provider. Though the post is a bit long this time, it includes a detailed step by step guide. It is always better to notify the support team or super admins when there are emails which are blocked by us. It will allow any important emails from being blocked.

Related articles

Signature Pad with Alpine.js

The post describes the implementation of signature pads with Alpine.js. It also discusses how to integrate it with laravel livewire.