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:
parent
85b039e739
commit
8bf40ee37e
25 changed files with 1312 additions and 4 deletions
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
Loading…
Add table
Add a link
Reference in a new issue