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