presseportale/app/Services/Mail/MailCircuitBreaker.php
Kevin Adametz 0a658bb34b mail: zentrale Bounce-Suppression mit Versand-Gate und Circuit Breaker (Roadmap 1b.6, Schritte 1–2)
email_suppressions/email_bounces als Zustand und Ereignis-Log; ein
MessageSending-Listener streicht gesperrte Empfänger aus JEDER ausgehenden
Mail und setzt den Return-Path. Zweiter Aufnahmepfad: SMTP-Ablehnungen im
Worker — 5.1.x sperrt den Empfänger, Relay-Ablehnungen zählen für den
Circuit Breaker, der den Versand anhält statt gesunde Adressen zu sperren.
X-Pk-Ref-Header auf den Mails, API-Meldungen fallen auf die Firmen-E-Mail
zurück, der Bounce-Import schreibt in die zentrale Liste.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-15 13:48:56 +00:00

83 lines
2.6 KiB
PHP

<?php
namespace App\Services\Mail;
use App\Support\OperationalAlert;
use Illuminate\Support\Carbon;
use Illuminate\Support\Facades\Cache;
/**
* Hält den Versand an, wenn das Relay UNS ablehnt (Konzept
* `bounce-mails-2026-09-15.md`: „bleibt Pflicht").
*
* Eine 554-Spam-Pattern-Ablehnung oder eine abgerissene Verbindung ist kein
* Empfängerproblem, sondern ein Absenderproblem — und jede weitere Mail in
* dem Zustand macht es schlimmer. Ab `threshold` Ablehnungen in
* `window_minutes` ist der Kreis für `pause_minutes` offen: Queue-Mails
* schlagen fehl (und lassen sich danach mit `queue:retry` nachholen),
* synchrone Mails werden still verworfen, der Admin bekommt genau eine Mail.
*
* Betriebs-Alerts gehen immer durch — sonst erführe niemand vom Stopp.
*/
class MailCircuitBreaker
{
private const CounterKey = 'mail.circuit.rejections';
private const OpenKey = 'mail.circuit.open_until';
public function recordRelayRejection(string $detail): void
{
$windowMinutes = max(1, (int) config('bounces.circuit_breaker.window_minutes', 15));
$threshold = max(1, (int) config('bounces.circuit_breaker.threshold', 5));
Cache::add(self::CounterKey, 0, now()->addMinutes($windowMinutes));
$count = (int) Cache::increment(self::CounterKey);
if ($count < $threshold || $this->isOpen()) {
return;
}
$pauseMinutes = max(1, (int) config('bounces.circuit_breaker.pause_minutes', 30));
$until = now()->addMinutes($pauseMinutes);
Cache::put(self::OpenKey, $until->toIso8601String(), $until);
Cache::forget(self::CounterKey);
OperationalAlert::send(
sprintf('Mailversand angehalten bis %s: %d Relay-Ablehnungen in %d Minuten', $until->format('H:i'), $count, $windowMinutes),
[
'last_rejection' => $detail,
'hint' => 'Absenderruf/Relay prüfen. Fehlgeschlagene Queue-Mails danach mit `php artisan queue:retry all` nachholen; `php artisan mail:circuit --reset` hebt die Pause vorzeitig auf.',
],
);
}
public function isOpen(): bool
{
return $this->openUntil() !== null;
}
public function openUntil(): ?Carbon
{
$until = Cache::get(self::OpenKey);
if (! is_string($until)) {
return null;
}
$carbon = Carbon::parse($until);
return $carbon->isFuture() ? $carbon : null;
}
public function reset(): void
{
Cache::forget(self::OpenKey);
Cache::forget(self::CounterKey);
}
public function pendingRejections(): int
{
return (int) Cache::get(self::CounterKey, 0);
}
}