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>
59 lines
2.6 KiB
PHP
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;
|
|
}
|
|
}
|