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));
}
}
}
<?php
namespace App\Mail;
use Illuminate\Support\Facades\DB;
final class SuppressionList
{
/** Hashed, so the block outlives deleting the contact. */
public static function key(string $address): string
{
return hash('sha256', mb_strtolower(trim($address)));
}
public static function add(string $address, string $reason): void
{
DB::table('suppressions')->upsert(
[['address_hash' => self::key($address), 'reason' => $reason, 'created_at' => now()]],
['address_hash'],
['reason'],
);
}
public static function blocks(string $address): bool
{
return DB::table('suppressions')->where('address_hash', self::key($address))->exists();
}
}
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:
complaintsuppresses immediately, because a person pressed "report spam".bouncewith 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
- Create
suppressions(address_hash unique, reason, created_at) and asoft_bouncescounter table or cache key. - Copy both classes into
app/Mail. - In your webhook controller, verify the signature and call
ProcessDeliveryEvent::handle($normalisedEvent). - Before every send run
if (SuppressionList::blocks($address)) { return; }. - 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.