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)'; } }