holzweg/bounce-mail-handler
Laravel package that processes bounced email notifications: parses inbound bounce messages, distinguishes hard from soft failures, and marks addresses undeliverable so future mailings skip them. Configurable mailbox and event hooks keep your list clean automatically.
Reality check before you write code. holzweg/bounce-mail-handler is not a Laravel package. There is no service provider, no facade, no publishable config, no queue integration, and no composer.json constraints you can trust. It is a PHP 5.x-era reboot of PHPMailer-BMH with PSR-0 autoloading and a legacy PEAR/ZF1 branch. Treat it as a parser you vendor behind an adapter, never as a framework dependency.
Minimal steps to a first run:
composer require holzweg/bounce-mail-handler, and try to instantiate the handler. If it explodes on removed functions (each(), create_function(), ereg*), you've just saved a week. Fall back to "use it as a specification" before you fall back to "fork it."ext-imap, mbstring, and usually iconv are non-negotiable. Laravel Sail's default image does not ship ext-imap.action_function callback. Adapt to the actual class name in the repo:
$bmh = new BounceMailHandler();
$bmh->mailhost = config('bounce.host');
$bmh->mailboxUserName = config('bounce.user');
$bmh->mailboxPassword = config('bounce.password');
$bmh->mailboxPort = 993;
$bmh->mailboxService = 'imap';
$bmh->mailboxServiceOption = '/ssl/novalidate-cert';
$bmh->actionFunction = fn ($msgnum, $bounce_type, $recipient, $details) => /* capture */;
$bmh->processMailbox();
bounce_events table. Do not delete from the mailbox, do not suppress recipients, do not send anything on the first run.Where to look first in the source:
BounceMailHandler class — the public properties are effectively your config surface.example.php (or equivalent in the repo root) — the intended usage contract.action_function callback signature — that's the only seam you'll wire into your app.First use case: an Artisan command (bounce:process) scheduled every five minutes, calling a BounceProcessor wrapper, logging recipient, classification (hard|soft|unknown), diagnostic_code, raw_message_id, and raw_headers to a table. That's it. Suppression logic comes later, after shadow mode proves the classifier.
Wrap it behind an application-owned contract on day one. The library must never leak past one directory (app/Services/Bounce). Everything else — commands, jobs, notifications, suppression — talks to the interface, never the vendored class. This is what lets you swap in SES/SendGrid webhooks later without rewriting callers.
interface BounceProcessor
{
/** @return ParsedBounce[] */
public function poll(): array;
}
final class ParsedBounce
{
public function __construct(
public readonly string $recipient,
public readonly string $classification, // hard|soft|unknown
public readonly ?string $diagnosticCode,
public readonly string $rawMessageId,
public readonly array $rawHeaders,
) {}
}
Adapter around the library. The adapter is the only class that knows the package exists. It translates the action_function callback into DTOs:
final class LegacyBmhAdapter implements BounceProcessor
{
public function poll(): array
{
$results = [];
$bmh = $this->makeHandler();
$bmh->actionFunction = function ($msgnum, $type, $recipient, $details) use (&$results) {
$results[] = new ParsedBounce(
recipient: (string) $recipient,
classification: $this->normalize((string) $type),
diagnosticCode: $this->extractDsn($details),
rawMessageId: $this->messageIdFrom($details),
rawHeaders: ['detail' => $details],
);
};
$bmh->processMailbox();
return $results;
}
}
Run it as a scheduled command, not a request handler. Bounce processing is a batch concern. Bind the interface to the adapter in a service provider, inject it into a console command, and give it a lock and a timeout so a hung IMAP socket doesn't stack up:
$schedule->command('bounce:process')
->everyFiveMinutes()
->withoutOverlapping()
->onOneServer()
->runInBackground();
Persist, then queue the reaction. Two-phase processing keeps the mailbox poll fast and the side effects retryable. Phase 1: poll, insert immutable bounce_events rows (idempotency key = Message-ID header, unique index). Phase 2: dispatch a HandleBounce job per new row — suppression updates, customer notifications, support tickets.
foreach ($processor->poll() as $bounce) {
$event = BounceEvent::firstOrCreate(
['message_id' => $bounce->rawMessageId],
['recipient' => $bounce->recipient, 'classification' => $bounce->classification, 'payload' => ...],
);
if ($event->wasRecentlyCreated) {
HandleBounce::dispatch($event)->onQueue('bounces');
}
}
Make destructive actions opt-in and disabled by default. The library can delete or move mailbox messages. Ship with bounce.mutate_mailbox = false, log "would delete" instead, and only flip it on after two weeks of clean shadow running. When you do enable it, prefer move to a processed folder over delete so you can replay.
Suppression persistence. A suppressed_emails table with email, reason (hard|complaint|manual), source_event_id, expires_at (soft bounces can expire; hard bounces should not). Apply at send time via a SuppressesAddresses trait or a Mailable base class — not by rewriting lists in place. Soft bounces: count them, promote to hard on N consecutive failures, not on first hit.
Event-driven extension. Fire Laravel events from the adapter or the HandleBounce job — BounceReceived, HardBounce, SoftBounce, BounceClassificationFailed — so support tooling, Slack alerts, and account managers subscribe without coupling to the handler.
Configuration surface. One config/bounce.php with driver, host, port, encryption, username, password (from env), mailbox, mutate, dry_run, max_messages_per_run. This is your future migration seam: driver=legacy becomes driver=ses with zero changes in callers.
Test against your own fixtures, not the package's (it has none). Export 20–50 real bounces from your provider into tests/Fixtures/bounces/ as .eml files. Write one test per file asserting the classification and recipient. This is your regression suite when you eventually port or swap implementations. Also add a test that feeds a non-bounce email to the parser and expects unknown, not a false hard bounce.
Use it as a reference implementation for classification. If the PHP 8.2 spike fails (likely), copy the regex tables into a Laravel-native BounceClassifier and feed it messages from webklex/php-imap or provider webhooks. You keep the domain value — the rule set — and drop the runtime liability.
each(), create_function(), ereg, curly-brace string offsets, and dynamic properties (fatal in 8.2) to surface. Run a spike before you commit. If it doesn't boot, stop — don't "just patch it" in the main app.legacy/ source set if the composer constraints fight your Laravel version, and don't let its dependency on old PHPMailer override yours — pin both with composer why checks.ext-imap is not always available. It's often missing in Alpine images and deprecated on newer PHP. If you can't install it, the package is dead in your runtime and you should jump straight to the port plan.Message-ID header with a unique index on your bounce_events table.max_messages_per_run, max_message_bytes) and avoid loading attachment bodies. Some bounces (Exchange) return the full original body — stream, don't slurp.From/To regexes. Normalize with mb_convert_encoding and punycode before comparing against your user table.mailboxPassword as a public property, set it at runtime from a secret accessor — never bind it through a config cache. config:cache will happily serialize it to disk otherwise if you map it wrong.dry_run flag that logs would_move and requires an explicit env var to flip. You do not want to discover a false-positive suppress by losing the source bounce in the same day.imap_fetchbody output alongside the classification in a quarantine column — cheap to store, invaluable when a customer emails support asking why they were suppressed.action_function and subclassing the handler are essentially it. Don't build deep overrides — that's a fork, and if you fork you own it: SBOM, CVE tracking, a named maintainer, and a quarterly review. Budget for it explicitly, or use the package as a reference and walk away.How can I help you explore Laravel packages today?