splitHeadersAndBody($raw); $headers = $this->parseHeaders($topHeaders); $parts = $this->allParts($topBody, $headers['content-type'] ?? ''); $status = null; $original = null; foreach ($parts as $part) { $type = strtolower((string) ($part['headers']['content-type'] ?? '')); if ($status === null && str_starts_with($type, 'message/delivery-status')) { $status = $part['body']; } elseif ($original === null && (str_starts_with($type, 'message/rfc822') || str_starts_with($type, 'text/rfc822-headers'))) { $original = $part['body']; } } if ($status === null) { return null; } $recipients = $this->recipientsFrom($status); if ($recipients === []) { return null; } $referenceHeader = strtolower((string) config('bounces.reference_header', 'X-Pk-Ref')); $originalHeaders = $original !== null ? $this->parseHeaders($this->splitHeadersAndBody(ltrim($original, "\n"))[0]) : []; return new DsnReport( messageId: trim((string) ($headers['message-id'] ?? ''), '<> ') ?: 'sha1:'.sha1($raw), recipients: $recipients, reference: $originalHeaders[$referenceHeader] ?? null, receivedAt: $this->date($headers['date'] ?? null), ); } /** * Grobe Erkennung ohne Parsen: für die Entscheidung, ob eine unlesbare * Mail eine Abwesenheitsnotiz ist (ignorieren) oder ein Rückläufer, den * ein Mensch ansehen sollte. */ public function looksLikeAutoReply(string $raw): bool { $raw = str_replace("\r\n", "\n", $raw); $headers = $this->parseHeaders($this->splitHeadersAndBody($raw)[0]); $autoSubmitted = strtolower((string) ($headers['auto-submitted'] ?? '')); $subject = strtolower((string) ($headers['subject'] ?? '')); return ($autoSubmitted !== '' && $autoSubmitted !== 'no') || isset($headers['x-autoreply']) || isset($headers['x-autorespond']) || preg_match('/abwesen|out of office|autoreply|automatische antwort|auto-reply/i', $subject) === 1; } /** * @return array{0: string, 1: string} */ private function splitHeadersAndBody(string $raw): array { $pos = strpos($raw, "\n\n"); return $pos === false ? [$raw, ''] : [substr($raw, 0, $pos), substr($raw, $pos + 2)]; } /** * Header mit Fortsetzungszeilen zusammengefaltet, Namen kleingeschrieben. * Bei Mehrfach-Headern gewinnt der erste. * * @return array */ private function parseHeaders(string $block): array { $unfolded = preg_replace('/\n[ \t]+/', ' ', $block) ?? $block; $headers = []; foreach (explode("\n", $unfolded) as $line) { if (preg_match('/^([A-Za-z0-9\-]+):\s*(.*)$/', $line, $m) === 1) { $headers[strtolower($m[1])] ??= trim($m[2]); } } return $headers; } /** * Alle MIME-Teile, rekursiv über verschachtelte Multiparts. * * @return list, body: string}> */ private function allParts(string $body, string $contentType): array { if (preg_match('/boundary="?([^";\s]+)"?/i', $contentType, $m) !== 1) { return []; } $boundary = $m[1]; $segments = preg_split('/^--'.preg_quote($boundary, '/').'(--)?[ \t]*$/m', $body) ?: []; $parts = []; // Erstes Segment ist der Präambel-Text vor dem ersten Boundary, // letztes der Epilog nach dem Schluss-Boundary. foreach (array_slice($segments, 1, -1) as $segment) { [$headerBlock, $partBody] = $this->splitHeadersAndBody(ltrim($segment, "\n")); $partHeaders = $this->parseHeaders($headerBlock); $parts[] = ['headers' => $partHeaders, 'body' => $partBody]; if (str_starts_with(strtolower((string) ($partHeaders['content-type'] ?? '')), 'multipart/')) { array_push($parts, ...$this->allParts($partBody, $partHeaders['content-type'])); } } return $parts; } /** * Der `message/delivery-status`-Teil: ein Block je Nachricht, dann je * Empfänger ein Block, durch Leerzeilen getrennt. Feldnamen sind * case-insensitiv. * * @return list */ private function recipientsFrom(string $status): array { $recipients = []; foreach (preg_split('/\n{2,}/', trim($status)) ?: [] as $block) { $fields = $this->parseHeaders($block); $recipient = $fields['final-recipient'] ?? $fields['original-recipient'] ?? null; if ($recipient === null) { continue; } // „rfc822; user@example.com" — der Adresstyp vor dem Semikolon. $email = EmailSuppressionService::normalise(trim((string) preg_replace('/^[^;]*;\s*/', '', $recipient))); $action = strtolower(trim((string) ($fields['action'] ?? ''))); if ($email === null || $action === '') { continue; } $diagnostic = isset($fields['diagnostic-code']) ? trim((string) preg_replace('/^[^;]*;\s*/', '', $fields['diagnostic-code'])) : null; $recipients[] = [ 'email' => $email, 'action' => $action, 'status' => isset($fields['status']) && preg_match('/^\d\.\d{1,3}\.\d{1,3}$/', trim($fields['status'])) === 1 ? trim($fields['status']) : null, 'diagnostic' => $diagnostic !== '' ? $diagnostic : null, ]; } return $recipients; } private function date(?string $value): ?Carbon { if ($value === null) { return null; } try { return Carbon::parse($value); } catch (\Throwable) { return null; } } }