F05: chunkById hinter der created_at-Sortierung der Vorschau las ab der
zweiten Seite Empfänger doppelt und ließ die übrigen aus. Auf der Dev-
Kopie erreichte eine Kampagne an 59.705 Nutzer 500 Adressen, davon 480
doppelt. Die Sortierung wird vor dem ID-Chunking verworfen (E4: die
Versandreihenfolge hat keine fachliche Bedeutung), ein Unique-Index auf
(Kampagne, User) fängt jede weitere Doppelung ab; die Migration räumt
vorhandene Doppelungen vorher weg (auf der Dev-Kopie 470 Zeilen).
F14: Der Start übernimmt den Entwurf per bedingtem UPDATE draft→sending;
nur der Gewinner friert Empfänger ein (insertOrIgnore) und plant Jobs
aus dem Protokoll ein. Abbruch beim Einfrieren gibt den Entwurf frei.
F15: Der Versandjob beansprucht die Zeile pending→sending, schreibt
Empfängerstatus, Zähler und Kampagnenabschluss in einer Transaktion und
gibt den Claim bei Versandfehler zurück. Tries und Backoff am Job.
Zusatzbefund: Adressen auf der Sperrliste galten als „gesendet“, weil der
Rückgabewert von Mail::send nicht ausgewertet wurde — jetzt Status
„suppressed“ ohne Zähler.
Neu: php artisan newsletter:resume {kampagne} [--failed]
[--stuck-minutes=30] plant ausstehende Empfänger einer laufenden
Kampagne neu ein; der Circuit-Breaker-Hinweis nennt ihn statt
queue:retry, das Newsletter-Empfänger nicht nachholt.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
84 lines
2.8 KiB
PHP
84 lines
2.8 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` bzw. für
|
|
* Newsletter mit `newsletter:resume --failed` 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, laufende Newsletter-Kampagnen mit `php artisan newsletter:resume {kampagne} --failed`; `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);
|
|
}
|
|
}
|