Developer Guides

Parse Email Delivery Status Notifications (DSN) in Laravel

Parse multipart DSN bounce messages in Laravel: extract recipient, action, status and diagnostic code with tested PHP, then decide what to suppress or retry.

Published

Published by Arawa Mail.

Parse DSN bounce messages in Laravel

When a bounce arrives in a mailbox as a multipart message, the useful data lives in the message/delivery-status part, not in the human-readable text. In Laravel you can extract the original recipient, Action, Status and Diagnostic-Code so you can suppress the exact address that failed. Prefer structured bounce events from your sending provider when they exist, and treat raw DSN parsing as the fallback for bounces that land in a mailbox. ArawaMail’s outgoing webhooks fire on incoming mailbox delivery only; they do not emit outbound bounce or DSN events.

What a Typical Multipart DSN Looks Like

A Delivery Status Notification, defined in RFC 3464, is sent as a multipart/report message with report-type=delivery-status (RFC 6522). It usually contains three parts:

  • A text/plain explanation written for humans
  • A message/delivery-status part with machine-readable fields
  • Optionally, the original message or its headers as message/rfc822 or text/rfc822-headers

The delivery-status part is a set of header-style groups separated by blank lines. The first group describes the whole report (for example Reporting-MTA and Arrival-Date). Each following group describes one recipient:

Reporting-MTA: dns; mx.example.net
Arrival-Date: Mon, 26 Oct 2026 10:00:00 +0000

Final-Recipient: rfc822; [email protected]
Action: failed
Status: 5.1.1
Diagnostic-Code: smtp; 550 5.1.1 The email account that you tried
 to reach does not exist

The fields that matter:

  • Final-Recipient (and optional Original-Recipient): the address that failed, prefixed with an address type such as rfc822;.
  • Action: one of failed, delayed, delivered, relayed or expanded.
  • Status: an enhanced status code in class.subject.detail form (RFC 3463). Class 5 is a permanent failure, class 4 is temporary, class 2 is success.
  • Diagnostic-Code: the remote server’s actual reply, useful for logs and support but too inconsistent to drive logic on its own.

For what to do once you have the bounce, see Bounce vs Complaint vs Delay and Amazon SES Suppression Lists.

When to Parse the DSN vs Use a Provider Webhook

Production systems should prefer structured bounce events from the sending provider (for example Amazon SES notifications, Postmark or Mailgun webhooks). Those give you the failed address and status without MIME parsing. Raw DSN parsing is the right fallback when:

  • The bounce lands in a Return-Path, catch-all or support mailbox
  • You ingest non-delivery reports over IMAP
  • Your provider does not expose a bounce webhook for that sending path

ArawaMail’s Outgoing Webhooks post each email that arrives in one of your mailboxes to your URL as JSON. They do not deliver outbound bounce or DSN events, so do not build on an ArawaMail bounce webhook. If DSNs are delivered to an ArawaMail mailbox, they arrive like any other incoming email; check the payload you actually receive before relying on it for parsing.

Related: Transactional email webhooks in Laravel and Next.js.

A Working DSN Parser in Plain PHP

The class below has no dependencies, so it runs in any Laravel app. It reads the top-level boundary, finds the message/delivery-status part (including the message/global-delivery-status variant), unfolds continued lines and returns one array per recipient. We tested it against the sample DSN above with PHP 8; it returned the recipient [email protected], action failed, status 5.1.1 and the full diagnostic line.

final class DsnParser
{
    /**
     * Extract per-recipient fields from a raw RFC 3464 multipart/report message.
     * Returns one array per recipient found in the message/delivery-status part.
     */
    public static function parse(string $raw): array
    {
        $raw = str_replace("\r\n", "\n", $raw);

        // 1. Find the multipart boundary in the top-level Content-Type header.
        [$headers] = explode("\n\n", $raw, 2) + [1 => ''];
        $headers = preg_replace("/\n[ \t]+/", ' ', $headers); // unfold headers
        if (!preg_match('/^Content-Type:\s*multipart\/report;.*boundary="?([^";\n]+)"?/im', $headers, $m)) {
            return [];
        }
        $boundary = $m[1];

        // 2. Split the body into parts and keep the message/delivery-status part.
        foreach (explode('--' . $boundary, $raw) as $part) {
            [$partHeaders, $partBody] = explode("\n\n", ltrim($part, "\n"), 2) + [1 => ''];
            if (!preg_match('/^Content-Type:\s*message\/(global-)?delivery-status/im', $partHeaders)) {
                continue;
            }

            // 3. Groups are separated by blank lines: the first is per-message,
            //    each following group describes one recipient.
            $groups = preg_split("/\n\s*\n/", trim($partBody));
            array_shift($groups);

            $results = [];
            foreach ($groups as $group) {
                $fields = [];
                $group = preg_replace("/\n[ \t]+/", ' ', $group);
                foreach (explode("\n", $group) as $line) {
                    if (str_contains($line, ':')) {
                        [$name, $value] = array_map('trim', explode(':', $line, 2));
                        $fields[strtolower($name)] = $value;
                    }
                }
                if (!isset($fields['final-recipient']) && !isset($fields['original-recipient'])) {
                    continue;
                }
                $recipient = $fields['original-recipient'] ?? $fields['final-recipient'];
                $results[] = [
                    'recipient'  => preg_replace('/^[^;]*;\s*/', '', $recipient),
                    'action'     => strtolower($fields['action'] ?? ''),
                    'status'     => $fields['status'] ?? '',
                    'diagnostic' => $fields['diagnostic-code'] ?? null,
                    'permanent'  => str_starts_with($fields['status'] ?? '', '5.'),
                ];
            }
            return $results;
        }

        return [];
    }
}

Limits to know: it handles one level of multipart, which covers standard RFC 3464 reports. It does not decode base64 or quoted-printable parts, and some older servers send non-standard bounces with no delivery-status part at all. For those, use a MIME library such as the PHP mailparse extension and fall back to logging the raw message for review.

Wiring It Into Laravel

Where the raw message comes from depends on your setup: an IMAP poller, a stored .eml file, or an inbound endpoint that receives the full MIME source. Keep the parsing in a queued job so the endpoint can respond quickly:

use App\Support\DsnParser;
use Illuminate\Support\Facades\Log;

foreach (DsnParser::parse($rawMessage) as $result) {
    if ($result['action'] === 'failed' && $result['permanent']) {
        // Add $result['recipient'] to your suppression table.
    } elseif ($result['action'] === 'delayed') {
        // Temporary problem: let normal retries continue.
    }

    Log::info('DSN processed', $result);
}

Deciding What to Suppress

  • 5.1.1 and other 5.1.x codes (bad mailbox or address): suppress the recipient.
  • Other 5.x.x codes: usually suppress, but read the diagnostic first; policy or content rejections can point to a sender-side problem rather than a bad address.
  • 4.x.x codes and Action: delayed: temporary. Do not suppress; let retries run and only act if the same address keeps failing.
  • Missing Status: log the raw message and review it by hand rather than guessing.

Store the status, diagnostic and timestamp with each suppression so you can audit or reverse it later.

FAQ

Does ArawaMail send bounce or DSN webhooks?

No. ArawaMail’s outgoing webhooks notify only when mail arrives in a mailbox. Use your sending provider’s bounce events, or parse DSNs that land in a mailbox.

Which field tells me the original recipient?

Final-Recipient, or Original-Recipient when it is present, inside the message/delivery-status part.

Should I suppress on every DSN?

No. Permanent failures (5.x.x) are candidates for suppression; temporary failures (4.x.x) usually just need retries.

Can I parse the human-readable text instead?

Avoid it. The wording differs between providers and languages, while the delivery-status fields are standardised.

Simple, transparent plans

Start free. Grow when your email does.

Get one domain, API access and 3,000 transactional emails every month at no cost.

Compare plans