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>
This commit is contained in:
Kevin Adametz 2026-09-15 13:48:56 +00:00
parent 87f95fb7a7
commit 0a658bb34b
25 changed files with 1312 additions and 4 deletions

View file

@ -4,9 +4,11 @@ namespace App\Console\Commands;
use App\Enums\ActivityType;
use App\Enums\EmailNotificationType;
use App\Enums\EmailSuppressionReason;
use App\Models\NewsletterSubscription;
use App\Models\User;
use App\Services\ActivityLog;
use App\Services\Mail\EmailSuppressionService;
use App\Services\Newsletter\NewsletterSyncService;
use Illuminate\Console\Command;
use Illuminate\Support\Str;
@ -42,6 +44,7 @@ class ImportNewsletterBounces extends Command
public function __construct(
private readonly ActivityLog $activityLog,
private readonly NewsletterSyncService $newsletterSync,
private readonly EmailSuppressionService $suppressions,
) {
parent::__construct();
}
@ -73,6 +76,13 @@ class ImportNewsletterBounces extends Command
$unknown = [];
foreach ($emails as $email) {
// Seit 1b.6 zuerst die zentrale Sperrliste: Sie wirkt auf JEDE
// Mailstrecke, nicht nur auf Newsletter — und auch auf Adressen,
// die weder Konto noch Anmeldung haben (Pressekontakte, Firmen).
if (! $dryRun) {
$this->suppressions->suppress($email, EmailSuppressionReason::HardBounce, 'import', 'Bounce-Liste '.basename($path));
}
$touchedUsers = $this->suppressUsers($email, $dryRun, $optedOut, $alreadyOptedOut);
$touchedSubscriptions = $this->suppressSubscriptions($email, $dryRun, $unsubscribed, $alreadyUnsubscribed);
@ -85,6 +95,7 @@ class ImportNewsletterBounces extends Command
}
$prefix = $dryRun ? '[Dry-Run] ' : '';
$this->info(sprintf('%s%d Adressen in die Sperrliste (email_suppressions) übernommen.', $prefix, count($emails)));
$this->info(sprintf(
'%s%d Adressen gelesen: %d abgemeldet, %d waren bereits abgemeldet, %d ohne User-Konto.',
$prefix,

View file

@ -0,0 +1,36 @@
<?php
namespace App\Console\Commands;
use App\Services\Mail\MailCircuitBreaker;
use Illuminate\Console\Command;
/**
* Zustand des Mail-Circuit-Breakers anzeigen oder die Pause aufheben.
*/
class MailCircuitCommand extends Command
{
protected $signature = 'mail:circuit {--reset : Pause aufheben und Zähler zurücksetzen}';
protected $description = 'Zeigt, ob der Mailversand angehalten ist (Circuit Breaker), und hebt die Pause auf Wunsch auf.';
public function handle(MailCircuitBreaker $breaker): int
{
if ($this->option('reset')) {
$breaker->reset();
$this->info('Circuit Breaker zurückgesetzt — der Versand läuft wieder. Liegengebliebene Mails: php artisan queue:retry all');
return self::SUCCESS;
}
$until = $breaker->openUntil();
if ($until !== null) {
$this->warn(sprintf('Versand ANGEHALTEN bis %s.', $until->format('d.m.Y H:i')));
} else {
$this->info(sprintf('Versand läuft. Relay-Ablehnungen im aktuellen Fenster: %d.', $breaker->pendingRejections()));
}
return self::SUCCESS;
}
}