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/plainexplanation written for humans - A
message/delivery-statuspart with machine-readable fields - Optionally, the original message or its headers as
message/rfc822ortext/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,relayedorexpanded. - Status: an enhanced status code in
class.subject.detailform (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.