presseportale/app/Services/Mail/SmtpFailureClassifier.php
Kevin Adametz 8bf40ee37e mail: zentrale Bounce-Suppression mit Versand-Gate und Circuit Breaker (Roadmap 1b.6, Schritte 1–2)
email_suppressions/email_bounces als Zustand und Ereignis-Log; ein
MessageSending-Listener streicht gesperrte Empfänger aus JEDER ausgehenden
Mail und setzt den Return-Path. Zweiter Aufnahmepfad: SMTP-Ablehnungen im
Worker — 5.1.x sperrt den Empfänger, Relay-Ablehnungen zählen für den
Circuit Breaker, der den Versand anhält statt gesunde Adressen zu sperren.
X-Pk-Ref-Header auf den Mails, API-Meldungen fallen auf die Firmen-E-Mail
zurück, der Bounce-Import schreibt in die zentrale Liste.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 13:48:56 +00:00

59 lines
2.6 KiB
PHP

<?php
namespace App\Services\Mail;
use App\Enums\EmailBounceKind;
/**
* Deutet die Fehlermeldung eines fehlgeschlagenen SMTP-Versands (Symfony
* `TransportException`). Zweiter Pfad des Konzepts: Manche Server weisen
* schon in der Sitzung ab statt einen DSN zu schicken.
*
* Konservativ, denn hier steht kein strukturierter Zustellbericht:
*
* - Nur `5.1.x` mit erkennbarer Empfängeradresse ist ein harter Bounce.
* (5.1.1 = Postfach existiert nicht, 5.1.2 = Domain existiert nicht.)
* - `4.x.x` mit Adresse ist ein weicher Bounce.
* - Alles andere — 5.7.x Policy, 554 Spam-Pattern, Verbindungsabbruch — ist
* eine Ablehnung des RELAYS gegen uns. Wer das als Bounce wertet, sperrt
* bei einem Reputationsproblem reihenweise gesunde Adressen.
*/
class SmtpFailureClassifier
{
/**
* @param list<string> $recipients Empfänger des Jobs — Rückfall, wenn die Meldung keine Adresse nennt und es genau einen gibt
* @return array{kind: EmailBounceKind, email: string|null, status: string|null, code: int|null}
*/
public function classify(string $message, array $recipients = []): array
{
$code = preg_match('/\b([45]\d{2})\b/', $message, $m) ? (int) $m[1] : null;
$status = preg_match('/\b([45]\.\d{1,3}\.\d{1,3})\b/', $message, $m) ? $m[1] : null;
$email = $this->recipientIn($message) ?? (count($recipients) === 1 ? EmailSuppressionService::normalise($recipients[0]) : null);
$isMailboxFailure = $status !== null && str_starts_with($status, '5.1.')
|| ($status === null && $code === 550 && preg_match('/user unknown|no such user|unknown user|does not exist|mailbox unavailable|recipient address rejected/i', $message) === 1);
if ($isMailboxFailure && $email !== null) {
return ['kind' => EmailBounceKind::Hard, 'email' => $email, 'status' => $status, 'code' => $code];
}
if (($status !== null && str_starts_with($status, '4.') || ($status === null && $code !== null && $code < 500)) && $email !== null) {
return ['kind' => EmailBounceKind::Soft, 'email' => $email, 'status' => $status, 'code' => $code];
}
return ['kind' => EmailBounceKind::Relay, 'email' => $email, 'status' => $status, 'code' => $code];
}
private function recipientIn(string $message): ?string
{
if (preg_match('/<([^<>\s]+@[^<>\s]+)>/', $message, $m) === 1) {
return EmailSuppressionService::normalise($m[1]);
}
if (preg_match('/([A-Z0-9._%+\-]+@[A-Z0-9.\-]+\.[A-Z]{2,})/i', $message, $m) === 1) {
return EmailSuppressionService::normalise($m[1]);
}
return null;
}
}