Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Bounce Mail Handler Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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:

  1. Prove it runs on your PHP first. Do not skip this. Spin up a throwaway Docker container on PHP 7.4 and 8.2, 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."
  2. Install the extensions. ext-imap, mbstring, and usually iconv are non-negotiable. Laravel Sail's default image does not ship ext-imap.
  3. Provision a dedicated bounce mailbox — never the outbound sender. IMAP, least-privilege, read-only, separate credentials in your secrets manager (Vault, AWS Secrets Manager, or env — not a committed config file).
  4. Instantiate and process manually. The PHPMailer-BMH-style API uses public properties plus an 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();
    
  5. Capture, don't act. Write every classification into a 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:

  • The main BounceMailHandler class — the public properties are effectively your config surface.
  • The bounce classification rules (regex arrays for hard/soft/DSN). These are the only part of the library with real domain value.
  • example.php (or equivalent in the repo root) — the intended usage contract.
  • The 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.

Implementation Patterns

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.

Gotchas and Tips

  • PHP 8 will bite. The codebase predates 8.x. Expect 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.
  • PSR-0 vs PSR-4. Composer will autoload it, but namespace collisions with the app are real. Vendor it into a separate 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.
  • No OAuth2 IMAP. Basic auth only. Gmail and Microsoft 365 now require OAuth2 or app passwords; if your provider has killed basic auth, this package cannot reach the mailbox. Confirm before writing anything else.
  • Idempotency is on you. The package doesn't dedupe across runs. Polling the same mailbox twice, or a crash mid-run, will re-deliver the same bounce. Always key off the Message-ID header with a unique index on your bounce_events table.
  • False hard bounces are the worst failure. A misclassification silently suppresses a paying customer. Never auto-suppress on the package's raw output — route the first 48 hours through a review queue, and add an allowlist bypass in your send path for flagged addresses.
  • Large messages and attachments will exhaust memory. Cap message size before parsing (max_messages_per_run, max_message_bytes) and avoid loading attachment bodies. Some bounces (Exchange) return the full original body — stream, don't slurp.
  • Charset and encoding landmines. Non-ASCII local parts and IDN domains break naive From/To regexes. Normalize with mb_convert_encoding and punycode before comparing against your user table.
  • Credentials in config files are the classic leak. If the package expects 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.
  • Never enable mailbox deletion on the first deploy. Even after shadow mode. Add a 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.
  • Debugging: dump the raw message. When a classification looks wrong, you need the original RFC822 bytes. Capture imap_fetchbody output alongside the classification in a quarantine column — cheap to store, invaluable when a customer emails support asking why they were suppressed.
  • Config caching and long-running workers. No service provider to worry about, but if you cache the parsed config, restart workers after changes. The library reads public properties at instantiation time, not per message.
  • Alert on the failure modes you can't see. Mailbox auth failure, zero-message runs that should have had traffic, parse-failure rate above X%, and suppression-count deltas. A silently dead bounce worker looks identical to "no bounces" until your sender reputation tanks.
  • Extension points are thin. 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.
Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
jinas/dhivatar
store-doc/laravel-storage-driver
plus-it/laravel-translation-manager
diephp/perhaps
diephp/telegraph
osmaviation/spreadsheet
microsoft/fluentui-system-icons
mmccook/php-json-canonicalization-scheme
middlewares/firewall
qubiqx/montapacking
nyoncode/laravel-package-console
pnscripts/filament-broadquill-lite
pnscripts/laravel-sentfold-lite
vleap/mx-sdk-laravel-internal
twidpay/utils
htetmyathlaing/scout-elasticsearch-driver
artisan-toolbox/front-awesome
ls/client-assistant
users-au/laravel-client
cgv/localized-resource