webklex/laravel-imap als Transport, eigener DSN-Parser nach RFC 3464 auf der rohen Nachricht: Final-Recipient, Action, Status, Diagnostic-Code und unser X-Pk-Ref aus der Original-Nachricht. Maßgeblich ist Action — Zustellbestätigungen sperren niemanden, Freitext-Rückläufer wandern nach Unparsed vor einen Menschen, Abwesenheitsnotizen nach Ignored. mail:fetch-bounces alle zehn Minuten; ohne Konto tut es nichts. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
198 lines
7.1 KiB
PHP
198 lines
7.1 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Mail;
|
|
|
|
use Illuminate\Support\Carbon;
|
|
|
|
/**
|
|
* Liest Zustellberichte (RFC 3464, `multipart/report; report-type=
|
|
* delivery-status`) aus der rohen Nachricht — ohne zu raten.
|
|
*
|
|
* Ein DSN hat drei Teile: lesbarer Text, `message/delivery-status` mit den
|
|
* Feldern je Empfänger, und die Original-Nachricht (oder deren Header). Der
|
|
* Parser braucht aus dem zweiten Teil genau vier Felder (`Final-Recipient`,
|
|
* `Action`, `Status`, `Diagnostic-Code`) und aus dem dritten unseren
|
|
* `X-Pk-Ref`. Maßgeblich ist `Action`: `failed` und `delayed` sind Bounces,
|
|
* `delivered`/`relayed`/`expanded` sind Zustellbestätigungen — wer nur nach
|
|
* `Final-Recipient` greift, sperrt Adressen, die funktioniert haben.
|
|
*
|
|
* Alles, was kein `message/delivery-status` trägt (handgeschriebene
|
|
* Ablehnungen, alte MTAs mit Freitext), liefert null. Das wandert in den
|
|
* Ordner `Unparsed` und vor einen Menschen — zwei Mails pro Woche von Hand
|
|
* sind akzeptabel, eine falsch gesperrte Adresse nicht.
|
|
*/
|
|
class DsnParser
|
|
{
|
|
public function parse(string $raw): ?DsnReport
|
|
{
|
|
$raw = str_replace("\r\n", "\n", $raw);
|
|
[$topHeaders, $topBody] = $this->splitHeadersAndBody($raw);
|
|
$headers = $this->parseHeaders($topHeaders);
|
|
|
|
$parts = $this->allParts($topBody, $headers['content-type'] ?? '');
|
|
$status = null;
|
|
$original = null;
|
|
|
|
foreach ($parts as $part) {
|
|
$type = strtolower((string) ($part['headers']['content-type'] ?? ''));
|
|
|
|
if ($status === null && str_starts_with($type, 'message/delivery-status')) {
|
|
$status = $part['body'];
|
|
} elseif ($original === null && (str_starts_with($type, 'message/rfc822') || str_starts_with($type, 'text/rfc822-headers'))) {
|
|
$original = $part['body'];
|
|
}
|
|
}
|
|
|
|
if ($status === null) {
|
|
return null;
|
|
}
|
|
|
|
$recipients = $this->recipientsFrom($status);
|
|
|
|
if ($recipients === []) {
|
|
return null;
|
|
}
|
|
|
|
$referenceHeader = strtolower((string) config('bounces.reference_header', 'X-Pk-Ref'));
|
|
$originalHeaders = $original !== null ? $this->parseHeaders($this->splitHeadersAndBody(ltrim($original, "\n"))[0]) : [];
|
|
|
|
return new DsnReport(
|
|
messageId: trim((string) ($headers['message-id'] ?? ''), '<> ') ?: 'sha1:'.sha1($raw),
|
|
recipients: $recipients,
|
|
reference: $originalHeaders[$referenceHeader] ?? null,
|
|
receivedAt: $this->date($headers['date'] ?? null),
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Grobe Erkennung ohne Parsen: für die Entscheidung, ob eine unlesbare
|
|
* Mail eine Abwesenheitsnotiz ist (ignorieren) oder ein Rückläufer, den
|
|
* ein Mensch ansehen sollte.
|
|
*/
|
|
public function looksLikeAutoReply(string $raw): bool
|
|
{
|
|
$raw = str_replace("\r\n", "\n", $raw);
|
|
$headers = $this->parseHeaders($this->splitHeadersAndBody($raw)[0]);
|
|
|
|
$autoSubmitted = strtolower((string) ($headers['auto-submitted'] ?? ''));
|
|
$subject = strtolower((string) ($headers['subject'] ?? ''));
|
|
|
|
return ($autoSubmitted !== '' && $autoSubmitted !== 'no')
|
|
|| isset($headers['x-autoreply'])
|
|
|| isset($headers['x-autorespond'])
|
|
|| preg_match('/abwesen|out of office|autoreply|automatische antwort|auto-reply/i', $subject) === 1;
|
|
}
|
|
|
|
/**
|
|
* @return array{0: string, 1: string}
|
|
*/
|
|
private function splitHeadersAndBody(string $raw): array
|
|
{
|
|
$pos = strpos($raw, "\n\n");
|
|
|
|
return $pos === false ? [$raw, ''] : [substr($raw, 0, $pos), substr($raw, $pos + 2)];
|
|
}
|
|
|
|
/**
|
|
* Header mit Fortsetzungszeilen zusammengefaltet, Namen kleingeschrieben.
|
|
* Bei Mehrfach-Headern gewinnt der erste.
|
|
*
|
|
* @return array<string, string>
|
|
*/
|
|
private function parseHeaders(string $block): array
|
|
{
|
|
$unfolded = preg_replace('/\n[ \t]+/', ' ', $block) ?? $block;
|
|
$headers = [];
|
|
|
|
foreach (explode("\n", $unfolded) as $line) {
|
|
if (preg_match('/^([A-Za-z0-9\-]+):\s*(.*)$/', $line, $m) === 1) {
|
|
$headers[strtolower($m[1])] ??= trim($m[2]);
|
|
}
|
|
}
|
|
|
|
return $headers;
|
|
}
|
|
|
|
/**
|
|
* Alle MIME-Teile, rekursiv über verschachtelte Multiparts.
|
|
*
|
|
* @return list<array{headers: array<string, string>, body: string}>
|
|
*/
|
|
private function allParts(string $body, string $contentType): array
|
|
{
|
|
if (preg_match('/boundary="?([^";\s]+)"?/i', $contentType, $m) !== 1) {
|
|
return [];
|
|
}
|
|
|
|
$boundary = $m[1];
|
|
$segments = preg_split('/^--'.preg_quote($boundary, '/').'(--)?[ \t]*$/m', $body) ?: [];
|
|
$parts = [];
|
|
|
|
// Erstes Segment ist der Präambel-Text vor dem ersten Boundary,
|
|
// letztes der Epilog nach dem Schluss-Boundary.
|
|
foreach (array_slice($segments, 1, -1) as $segment) {
|
|
[$headerBlock, $partBody] = $this->splitHeadersAndBody(ltrim($segment, "\n"));
|
|
$partHeaders = $this->parseHeaders($headerBlock);
|
|
$parts[] = ['headers' => $partHeaders, 'body' => $partBody];
|
|
|
|
if (str_starts_with(strtolower((string) ($partHeaders['content-type'] ?? '')), 'multipart/')) {
|
|
array_push($parts, ...$this->allParts($partBody, $partHeaders['content-type']));
|
|
}
|
|
}
|
|
|
|
return $parts;
|
|
}
|
|
|
|
/**
|
|
* Der `message/delivery-status`-Teil: ein Block je Nachricht, dann je
|
|
* Empfänger ein Block, durch Leerzeilen getrennt. Feldnamen sind
|
|
* case-insensitiv.
|
|
*
|
|
* @return list<array{email: string, action: string, status: string|null, diagnostic: string|null}>
|
|
*/
|
|
private function recipientsFrom(string $status): array
|
|
{
|
|
$recipients = [];
|
|
|
|
foreach (preg_split('/\n{2,}/', trim($status)) ?: [] as $block) {
|
|
$fields = $this->parseHeaders($block);
|
|
$recipient = $fields['final-recipient'] ?? $fields['original-recipient'] ?? null;
|
|
|
|
if ($recipient === null) {
|
|
continue;
|
|
}
|
|
|
|
// „rfc822; user@example.com" — der Adresstyp vor dem Semikolon.
|
|
$email = EmailSuppressionService::normalise(trim((string) preg_replace('/^[^;]*;\s*/', '', $recipient)));
|
|
$action = strtolower(trim((string) ($fields['action'] ?? '')));
|
|
|
|
if ($email === null || $action === '') {
|
|
continue;
|
|
}
|
|
|
|
$diagnostic = isset($fields['diagnostic-code']) ? trim((string) preg_replace('/^[^;]*;\s*/', '', $fields['diagnostic-code'])) : null;
|
|
|
|
$recipients[] = [
|
|
'email' => $email,
|
|
'action' => $action,
|
|
'status' => isset($fields['status']) && preg_match('/^\d\.\d{1,3}\.\d{1,3}$/', trim($fields['status'])) === 1 ? trim($fields['status']) : null,
|
|
'diagnostic' => $diagnostic !== '' ? $diagnostic : null,
|
|
];
|
|
}
|
|
|
|
return $recipients;
|
|
}
|
|
|
|
private function date(?string $value): ?Carbon
|
|
{
|
|
if ($value === null) {
|
|
return null;
|
|
}
|
|
|
|
try {
|
|
return Carbon::parse($value);
|
|
} catch (\Throwable) {
|
|
return null;
|
|
}
|
|
}
|
|
}
|