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 85b039e739
commit 8bf40ee37e
25 changed files with 1312 additions and 4 deletions

View file

@ -0,0 +1,67 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Bounce-Suppression (Roadmap 1b.6, Konzept `bounce-mails-2026-09-15.md`).
*
* Zwei Tabellen, weil Ereignis und Zustand verschiedene Dinge sind:
*
* - `email_bounces` ist das Log: ein Rückläufer, ein SMTP-Fehlschlag, ein
* Import — je Ereignis eine Zeile, idempotent über `message_id`. Ohne die
* Unique-Prüfung zählte ein abgebrochener Abhol-Lauf denselben Soft Bounce
* dreimal und sperrte zu früh.
* - `email_suppressions` ist der Zustand: eine Zeile je Adresse, aus den
* Ereignissen abgeleitet. `suppressed_at` gesetzt heißt „nicht erreichbar";
* `released_at` ist die Freigabe im Admin, wenn jemand die Adresse
* repariert hat.
*
* Wirkung: Der Versand-Listener (GateOutgoingMail) prüft JEDE ausgehende Mail
* gegen `email_suppressions` — Magic Links, Benachrichtigungen, Newsletter.
* Eine Stelle, keine Strecke kann es vergessen.
*/
return new class extends Migration
{
public function up(): void
{
Schema::create('email_suppressions', function (Blueprint $table) {
$table->id();
$table->string('email', 190)->unique();
$table->string('reason', 32);
$table->string('source', 32);
$table->unsignedInteger('hard_bounce_count')->default(0);
$table->unsignedInteger('soft_bounce_count')->default(0);
$table->timestamp('first_bounced_at')->nullable();
$table->timestamp('last_bounced_at')->nullable();
$table->timestamp('suppressed_at')->nullable();
$table->timestamp('released_at')->nullable();
$table->foreignId('released_by_user_id')->nullable()->constrained('users')->nullOnDelete();
$table->string('note', 500)->nullable();
$table->timestamps();
$table->index('suppressed_at', 'email_suppressions_suppressed_idx');
});
Schema::create('email_bounces', function (Blueprint $table) {
$table->id();
$table->string('message_id', 255)->unique();
$table->string('email', 190)->index();
$table->string('kind', 16);
$table->string('source', 32);
$table->string('action', 32)->nullable();
$table->string('status', 16)->nullable();
$table->string('diagnostic', 500)->nullable();
$table->string('reference', 120)->nullable();
$table->timestamp('received_at');
$table->timestamp('created_at')->nullable();
});
}
public function down(): void
{
Schema::dropIfExists('email_bounces');
Schema::dropIfExists('email_suppressions');
}
};