active() ->where('email', $key) ->when(! $includeUnsubscribed, fn ($q) => $q->where('reason', '!=', EmailSuppressionReason::Unsubscribed->value)) ->exists(); } /** * Welche dieser Adressen sind gesperrt? Eine Abfrage für alle Empfänger * einer Mail. * * @param list $emails * @return list normalisiert */ public function suppressedAmong(array $emails, bool $includeUnsubscribed = true): array { $keys = array_values(array_filter(array_map(self::normalise(...), $emails))); if ($keys === []) { return []; } return EmailSuppression::query() ->active() ->whereIn('email', $keys) ->when(! $includeUnsubscribed, fn ($q) => $q->where('reason', '!=', EmailSuppressionReason::Unsubscribed->value)) ->pluck('email') ->all(); } /** * Abmeldung durch den Empfänger (Roadmap 1d.3): für Adressen ohne Konto * — Pressekontakt, Firmen-E-Mail — ist die Sperrliste der einzige Ort, * an dem „will keine Benachrichtigungen" stehen kann. Eine bestehende * Zustellsperre bleibt, was sie ist. */ public function unsubscribe(string $email, string $method = 'link'): ?EmailSuppression { $key = self::normalise($email); if ($key === null) { return null; } $existing = EmailSuppression::query()->active()->where('email', $key)->first(); if ($existing !== null && $existing->reason !== EmailSuppressionReason::Unsubscribed) { return $existing; } return $this->suppress($key, EmailSuppressionReason::Unsubscribed, 'unsubscribe', 'Abmeldung durch Empfänger ('.$method.')'); } /** * Abmeldung zurücknehmen — nur eine Abmeldung; Zustellsperren löst nur * der Admin. */ public function resubscribe(string $email): bool { $key = self::normalise($email); $suppression = $key === null ? null : EmailSuppression::query()->active()->where('email', $key)->where('reason', EmailSuppressionReason::Unsubscribed->value)->first(); if ($suppression === null) { return false; } $this->release($suppression, null, 'Abmeldung durch Empfänger zurückgenommen'); return true; } /** * Ein Rückläufer-Ereignis. Idempotent über die Message-ID: Kommt dieselbe * Mail ein zweites Mal (abgebrochener Abhol-Lauf), passiert nichts. * * @param array{action?: string|null, status?: string|null, diagnostic?: string|null, reference?: string|null, received_at?: Carbon|null} $details * @return EmailBounce|null null, wenn das Ereignis schon bekannt war */ public function recordBounce(string $email, EmailBounceKind $kind, string $source, string $messageId, array $details = []): ?EmailBounce { $key = self::normalise($email); if ($key === null) { return null; } try { $bounce = EmailBounce::query()->create([ 'message_id' => mb_substr($messageId, 0, 255), 'email' => $key, 'kind' => $kind, 'source' => $source, 'action' => isset($details['action']) ? mb_substr($details['action'], 0, 32) : null, 'status' => isset($details['status']) ? mb_substr($details['status'], 0, 16) : null, 'diagnostic' => isset($details['diagnostic']) ? mb_substr($details['diagnostic'], 0, 500) : null, 'reference' => isset($details['reference']) ? mb_substr($details['reference'], 0, 120) : null, 'received_at' => $details['received_at'] ?? now(), ]); } catch (UniqueConstraintViolationException) { return null; } if ($kind === EmailBounceKind::Relay || $kind === EmailBounceKind::Policy) { // Kein Empfängerproblem — nur protokolliert. Relay zählt der // Circuit Breaker separat, Richtlinien-Ablehnungen tauchen im // Einlieferer-Bericht auf und sind Sache eines Menschen. return $bounce; } $this->applyBounce($key, $kind, $source, $bounce); return $bounce; } /** * Sperre von Hand oder aus einer Bounce-Liste (Import). */ public function suppress(string $email, EmailSuppressionReason $reason, string $source, ?string $note = null): ?EmailSuppression { $key = self::normalise($email); if ($key === null) { return null; } $suppression = EmailSuppression::query()->firstOrNew(['email' => $key]); $wasActive = $suppression->exists && $suppression->isActive(); $suppression->fill([ 'reason' => $reason, 'source' => $source, 'suppressed_at' => now(), 'released_at' => null, 'released_by_user_id' => null, 'note' => $note !== null ? mb_substr($note, 0, 500) : $suppression->note, ])->save(); if (! $wasActive) { $this->activityLog->record( ActivityType::MailSuppressed, sprintf('%s gesperrt: %s (%s)', $key, $reason->label(), $source), subject: $suppression, actorLabel: $source, ); } return $suppression; } /** * Freigabe im Admin, wenn jemand die Adresse repariert hat. Ein späterer * Bounce setzt die Sperre neu. */ public function release(EmailSuppression $suppression, ?User $by = null, ?string $note = null): void { $suppression->forceFill([ 'released_at' => now(), 'released_by_user_id' => $by?->getKey(), 'note' => $note !== null ? mb_substr($note, 0, 500) : $suppression->note, ])->save(); $this->activityLog->record( ActivityType::MailSuppressed, sprintf('%s freigegeben', $suppression->email), user: $by, subject: $suppression, ); } private function applyBounce(string $email, EmailBounceKind $kind, string $source, EmailBounce $bounce): void { $suppression = EmailSuppression::query()->firstOrNew(['email' => $email], [ 'reason' => EmailSuppressionReason::HardBounce, 'source' => $source, ]); $wasActive = $suppression->exists && $suppression->isActive(); $suppression->first_bounced_at ??= $bounce->received_at; $suppression->last_bounced_at = $bounce->received_at; if ($kind === EmailBounceKind::Hard) { $suppression->hard_bounce_count++; $suppression->reason = EmailSuppressionReason::HardBounce; $suppression->source = $source; $suppression->suppressed_at = now(); $suppression->released_at = null; $suppression->released_by_user_id = null; } else { $suppression->soft_bounce_count++; $recentSoft = EmailBounce::query() ->where('email', $email) ->where('kind', EmailBounceKind::Soft->value) ->where('received_at', '>=', now()->subDays((int) config('bounces.soft.window_days', 7))) ->count(); if (! $wasActive && $recentSoft >= (int) config('bounces.soft.threshold', 3)) { $suppression->reason = EmailSuppressionReason::SoftBounces; $suppression->source = $source; $suppression->suppressed_at = now(); $suppression->released_at = null; $suppression->released_by_user_id = null; } } $suppression->save(); if (! $wasActive && $suppression->isActive()) { $this->activityLog->record( ActivityType::MailSuppressed, sprintf('%s gesperrt: %s (%s%s)', $email, $suppression->reason->label(), $source, $bounce->status ? ', '.$bounce->status : ''), subject: $suppression, actorLabel: $source, meta: array_filter(['reference' => $bounce->reference, 'diagnostic' => $bounce->diagnostic]), ); } } }