presseportale/app/Services/Mail/BounceMailProcessor.php
Kevin Adametz ada41cb067 mail: Kontakterreichbarkeit - Formpruefung, Domainpruefung, Einlieferer-Bericht
Vier Punkte aus kontakterreichbarkeit-mx-bounces-2026-09-15.md, ohne
Veroeffentlichungssperre:

- PlausibleEmailDomain: Domain ohne Endung oder mit Tippfehler-TLD ist
  ein 422 an der Legacy-API, deterministisch ohne DNS.
- email_domain_checks + MailDomainChecker ueber DnsResolver-Schnittstelle
  (RFC 5321 impliziter MX, RFC 7505 Null MX, Unresolved ist kein Befund),
  Observer an Company/Contact, Job nach Commit, taeglicher Refresh,
  mail:check-domains fuer den Bestand. Nie eine Adresssperre aus DNS.
- Admin > Reports > Einlieferer-Bericht: tote Adressen aus Sperrliste
  und Domainbefund, automatisch abgelehnte Meldungen mit Grund,
  Text zum Kopieren und CSV. Versendet nichts.
- EmailBounceKind::Policy: 5.7.x wird protokolliert, zaehlt aber nicht
  auf den Schwellwert. Statuscode entscheidet vor Diagnosetext.

Messung vorab: 6 von 877 API-Domains ohne Zustellweg, eine davon fuer
66 der 151 Ruecklaeufer verantwortlich.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 09:22:39 +00:00

142 lines
4.7 KiB
PHP

<?php
namespace App\Services\Mail;
use App\Enums\ActivityType;
use App\Enums\EmailBounceKind;
use App\Services\ActivityLog;
/**
* Was mit einer Mail aus dem Bounce-Postfach geschieht — getrennt vom
* Abholen, damit es sich mit .eml-Fixtures testen lässt.
*
* Ergebnis ist der Ziel-Ordner: `Processed` (Zustellbericht gelesen),
* `Ignored` (Abwesenheitsnotiz oder reine Zustellbestätigung) oder
* `Unparsed` (kein lesbarer Bericht — sieht sich ein Mensch an).
*/
class BounceMailProcessor
{
public const Processed = 'Processed';
public const Ignored = 'Ignored';
public const Unparsed = 'Unparsed';
public function __construct(
private readonly DsnParser $parser,
private readonly EmailSuppressionService $suppressions,
private readonly ActivityLog $activityLog,
) {}
/**
* @return array{folder: string, bounces: int, subject: string}
*/
public function process(string $raw, bool $dryRun = false): array
{
$subject = $this->subject($raw);
$report = $this->parser->parse($raw);
if ($report === null) {
if ($this->parser->looksLikeAutoReply($raw)) {
return ['folder' => self::Ignored, 'bounces' => 0, 'subject' => $subject];
}
if (! $dryRun) {
$this->activityLog->record(
ActivityType::MailSuppressed,
sprintf('Rückläufer nicht lesbar, im Postfach unter „Unparsed“: %s', $subject),
actorLabel: 'imap',
);
}
return ['folder' => self::Unparsed, 'bounces' => 0, 'subject' => $subject];
}
$bounces = 0;
foreach ($report->recipients as $index => $recipient) {
$kind = match ($recipient['action']) {
'failed' => EmailBounceKind::Hard,
'delayed' => EmailBounceKind::Soft,
default => null,
};
if ($kind === null) {
continue;
}
if ($kind === EmailBounceKind::Hard) {
$kind = $this->reclassify($recipient['status'], $recipient['diagnostic']);
}
$bounces++;
if ($dryRun) {
continue;
}
$this->suppressions->recordBounce(
$recipient['email'],
$kind,
'imap',
$report->messageId.($index > 0 ? '#'.$index : ''),
[
'action' => $recipient['action'],
'status' => $recipient['status'],
'diagnostic' => $recipient['diagnostic'],
'reference' => $report->reference,
'received_at' => $report->receivedAt,
],
);
}
return ['folder' => $bounces > 0 ? self::Processed : self::Ignored, 'bounces' => $bounces, 'subject' => $subject];
}
/**
* `failed` heißt nicht immer „Adresse existiert nicht". Drei Gruppen:
*
* - **Richtlinie** (5.7.x, „Relay access denied", Spamfilter,
* Transportregel): Der Empfängerserver lehnt uns ab, das Postfach kann
* existieren. Nur protokollieren, nie zählen.
* - **Vorübergehend** (4.x.x, das der MTA aufgegeben hat; volles Postfach
* 5.2.2/5.2.5 bzw. SMTP 552): weich, Sperre erst ab Schwellwert.
* - Alles andere bleibt hart.
*
* Bestand vom 15.09.2026: 8 Richtlinien- und 8 vorübergehende Fälle unter
* 147 DSNs.
*/
private function reclassify(?string $status, ?string $diagnostic): EmailBounceKind
{
$status = (string) $status;
$diagnostic = (string) $diagnostic;
// Der Statuscode entscheidet, wo er eindeutig ist: 5.1.x heißt
// „Empfänger unbekannt" — auch wenn im Diagnosetext „spam" steht,
// wie beim Freitext-Hinweis des eigenen Relays.
if (str_starts_with($status, '5.1.')) {
return EmailBounceKind::Hard;
}
if (str_starts_with($status, '5.7.')) {
return EmailBounceKind::Policy;
}
if (str_starts_with($status, '4.')
|| in_array($status, ['5.2.2', '5.2.5'], true)
|| preg_match('/^\s*(4\d\d|552)\b/', $diagnostic) === 1) {
return EmailBounceKind::Soft;
}
if (preg_match('/relay access denied|blocked using|rejected by organization|transport\.rules|reject filter|spam(filter|haus| pattern)/i', $diagnostic) === 1) {
return EmailBounceKind::Policy;
}
return EmailBounceKind::Hard;
}
private function subject(string $raw): string
{
return preg_match('/^Subject:\s*(.*)$/mi', $raw, $m) === 1 ? mb_substr(trim($m[1]), 0, 200) : '(ohne Betreff)';
}
}