DEV3 Technologies home

Bounce and complaint webhook processing

Turn provider bounce and spam-complaint events into a suppression list, treating hard bounces at once and soft bounces only after repeats.

LaravelWebhooksemailbouncesuppressionwebhook

Code

<?php

namespace App\Mail;

use Illuminate\Support\Facades\Cache;

class ProcessDeliveryEvent
{
    private const SOFT_LIMIT = 3;

    /** @param array{type: string, address: string, permanent?: bool, reason?: string} $event */
    public static function handle(array $event): void
    {
        $address = $event['address'];

        match (true) {
            $event['type'] === 'complaint' => SuppressionList::add($address, 'complaint'),
            $event['type'] === 'bounce' && ($event['permanent'] ?? false) => SuppressionList::add($address, 'hard_bounce'),
            $event['type'] === 'bounce' => self::softBounce($address, $event['reason'] ?? ''),
            default => null,
        };
    }

    private static function softBounce(string $address, string $reason): void
    {
        $key = 'soft-bounce:'.SuppressionList::key($address);

        Cache::add($key, 0, now()->addWeek()); // rolling window starts at the first strike

        if (Cache::increment($key) >= self::SOFT_LIMIT) {
            SuppressionList::add($address, 'soft_bounce: '.mb_substr($reason, 0, 80));
        }
    }
}

How it works

What it solves

Mailing addresses that bounce or complain damages your sender reputation quickly. Providers tell you about both through webhooks, but each uses its own field names. You need one place that decides which events stop future sending, and that does it consistently.

How it works

ProcessDeliveryEvent takes a normalised event array (type, address, reason) that your provider adapter has already extracted. A match on the type decides the action:

  • complaint suppresses immediately, because a person pressed "report spam".
  • bounce with a permanent flag, such as a mailbox that does not exist, also suppresses immediately.
  • A transient bounce, such as a full inbox, increments a counter and only suppresses at the third strike inside a rolling week.

SuppressionList is a small gateway over a suppressions table keyed by a lowercase hash of the address. add() uses upsert, so repeated events are harmless, and blocks() is the one query the sender calls before every message.

Storing a hash rather than the address lets you keep the block list after you delete the contact for privacy reasons.

Adapting it

Write one adapter per provider that maps its payload to the normalised shape. Add a delivered branch to reset the soft bounce counter when you want a forgiving policy.

Pitfalls

  • Compare addresses case-insensitively before hashing.
  • Authenticate the webhook before calling this class.
  • Keep the complaint reason; it is useful when a customer disputes a block.

How to use it

  1. Create suppressions (address_hash unique, reason, created_at) and a soft_bounces counter table or cache key.
  2. Copy both classes into app/Mail.
  3. In your webhook controller, verify the signature and call ProcessDeliveryEvent::handle($normalisedEvent).
  4. Before every send run if (SuppressionList::blocks($address)) { return; }.
  5. Show the suppression reason in your contact screen for support staff.

Licence: DEV3 Snippet Licence. Free to use, change and ship in personal and commercial projects. Attribution is appreciated, not required. Please do not redistribute snippets as a collection.

Related snippets