- R-02/K-03: Aufrufaggregation und Merge ändern updated_at nicht mehr; neue Spalte press_releases.content_changed_at (INSTANT), contentModifiedAt() für Sitemap-lastmod, dateModified und article:modified_time - R-03: Sitemap und Legacy-view_url mit /en/ für englische Meldungen - R-04/O01: X-Pk-Ref je Empfänger per HMAC signiert; auf eine Sperre zählt — hart wie weich — nur ein gültig signierter Rückläufer, unsignierte standardmäßig nur zur Sichtung (BOUNCE_UNSIGNED_KIND=policy) - R-05/R-06: Unmerge setzt nur unveränderte Füllwerte zurück (company_merge_moves.merged_value), Canonical-Neubewertung nach Merge und Unmerge, assignAll setzt veraltete Verweise zurück - R-07 bis R-11: Crawler zählen nicht als Aufruf, Mail-Klick gedrosselt und verzögert geschrieben, Claim-Zähler erst nach Versand, email_bounces nach 395 Tagen gelöscht, Canonical-Portal über Kategorie-ID, llms-Cache geleert, Feed-Teaser ohne Doppel-Escaping, Merge-Ketten aufgelöst - N17/N18: kein falsches „gelistet seit", Rollen im Indexbericht wie Sitemap - OPT-1/OPT-2: topFeeds in SQL, purge-events löscht nach Grenz-ID Enthält aus PressRelease.php auch die Card-Bild-Helfer (B-01) und wasEverPublished()/neverPublished() (A-05), weil die Datei ungeteilt committet ist. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
189 lines
6.9 KiB
PHP
189 lines
6.9 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,
|
|
private readonly BounceReference $references,
|
|
) {}
|
|
|
|
/**
|
|
* @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']);
|
|
}
|
|
|
|
$reference = $this->references->plain($report->reference);
|
|
$verified = $this->references->verify($report->reference, $recipient['email']);
|
|
|
|
// Jede Art, die auf eine Sperre zählt — hart wie weich —, braucht
|
|
// den signierten Nachweis für genau diesen Empfänger
|
|
// (Umsetzungsaudit 02.10.2026, O01: der weiche Weg lief vorbei).
|
|
if ($kind !== EmailBounceKind::Policy && ! $verified) {
|
|
[$kind, $reference] = $this->unverified($report->reference, $reference);
|
|
}
|
|
|
|
$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' => $reference,
|
|
'received_at' => $report->receivedAt,
|
|
],
|
|
);
|
|
}
|
|
|
|
return ['folder' => $bounces > 0 ? self::Processed : self::Ignored, 'bounces' => $bounces, 'subject' => $subject];
|
|
}
|
|
|
|
/**
|
|
* Bounce ohne passende Signatur (Umsetzungsaudit 29.09.2026, R-04, und
|
|
* 02.10.2026, O01): Jeder kann ein `multipart/report` ans Return-Path-
|
|
* Postfach schicken. Auf eine Sperre zählen darf — hart wie weich — nur
|
|
* ein Bericht, dessen `X-Pk-Ref` wir für genau diesen Empfänger signiert
|
|
* haben.
|
|
*
|
|
* - Signatur vorhanden, aber falsch (verändert, oder die echte Signatur
|
|
* einer Mail an eine andere Adresse): Richtlinie — nur protokolliert,
|
|
* zur Sichtung im Admin, zählt auf keinen Schwellwert.
|
|
* - Gar keine Signatur (vor der Umstellung versendet, Freitext-Bericht
|
|
* ohne Original-Header): `bounces.unsigned_kind`, Standard ebenfalls
|
|
* Richtlinie. `soft` zählt bewusst auf den Schwellwert und ist damit
|
|
* fälschbar; hart wird ein unsignierter Bericht nie.
|
|
*
|
|
* @return array{0: EmailBounceKind, 1: string|null}
|
|
*/
|
|
private function unverified(?string $header, ?string $reference): array
|
|
{
|
|
if ($this->references->isSigned($header)) {
|
|
return [EmailBounceKind::Policy, $this->annotate($reference, 'Signatur ungültig')];
|
|
}
|
|
|
|
$kind = EmailBounceKind::tryFrom((string) config('bounces.unsigned_kind', 'policy'));
|
|
|
|
return [
|
|
$kind === EmailBounceKind::Soft ? EmailBounceKind::Soft : EmailBounceKind::Policy,
|
|
$this->annotate($reference, 'unsigniert'),
|
|
];
|
|
}
|
|
|
|
private function annotate(?string $reference, string $note): string
|
|
{
|
|
return $reference === null ? '('.$note.')' : mb_substr($reference, 0, 100).' ('.$note.')';
|
|
}
|
|
|
|
/**
|
|
* `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)';
|
|
}
|
|
}
|