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

@ -0,0 +1,212 @@
<?php
namespace App\Services\Mail;
use App\Enums\ActivityType;
use App\Enums\EmailBounceKind;
use App\Enums\EmailSuppressionReason;
use App\Models\EmailBounce;
use App\Models\EmailSuppression;
use App\Models\User;
use App\Services\ActivityLog;
use Illuminate\Database\UniqueConstraintViolationException;
use Illuminate\Support\Carbon;
/**
* Die eine Stelle, die „nicht erreichbar" bedeutet (Roadmap 1b.6).
*
* Schreibt Rückläufer-Ereignisse nach `email_bounces` und leitet daraus den
* Zustand in `email_suppressions` ab: harter Bounce → sofort gesperrt,
* weiche → ab dem dritten in sieben Tagen. Gelesen vom Versand-Gate
* (GateOutgoingMail), von der Claim-Seite und vom Admin.
*
* Adressen werden normalisiert wie überall im Projekt: Kleinschreibung,
* getrimmt. Bounce-Listen kommen aus dem Mailserver-Log und geben die
* Adresse so wieder, wie der Absender sie geschrieben hat.
*/
class EmailSuppressionService
{
public function __construct(private readonly ActivityLog $activityLog) {}
public static function normalise(?string $email): ?string
{
$email = mb_strtolower(trim((string) $email));
return $email === '' ? null : $email;
}
public function isSuppressed(?string $email): bool
{
$key = self::normalise($email);
if ($key === null) {
return false;
}
return EmailSuppression::query()->active()->where('email', $key)->exists();
}
/**
* Welche dieser Adressen sind gesperrt? Eine Abfrage für alle Empfänger
* einer Mail.
*
* @param list<string> $emails
* @return list<string> normalisiert
*/
public function suppressedAmong(array $emails): array
{
$keys = array_values(array_filter(array_map(self::normalise(...), $emails)));
if ($keys === []) {
return [];
}
return EmailSuppression::query()->active()->whereIn('email', $keys)->pluck('email')->all();
}
/**
* Ein Rückläufer-Ereignis. Idempotent über die Message-ID: Kommt dieselbe
* Mail ein zweites Mal (abgebrochener Abhol-Lauf), passiert nichts.
*
* @param array{action?: string|null, status?: string|null, diagnostic?: string|null, reference?: string|null, received_at?: Carbon|null} $details
* @return EmailBounce|null null, wenn das Ereignis schon bekannt war
*/
public function recordBounce(string $email, EmailBounceKind $kind, string $source, string $messageId, array $details = []): ?EmailBounce
{
$key = self::normalise($email);
if ($key === null) {
return null;
}
try {
$bounce = EmailBounce::query()->create([
'message_id' => mb_substr($messageId, 0, 255),
'email' => $key,
'kind' => $kind,
'source' => $source,
'action' => isset($details['action']) ? mb_substr($details['action'], 0, 32) : null,
'status' => isset($details['status']) ? mb_substr($details['status'], 0, 16) : null,
'diagnostic' => isset($details['diagnostic']) ? mb_substr($details['diagnostic'], 0, 500) : null,
'reference' => isset($details['reference']) ? mb_substr($details['reference'], 0, 120) : null,
'received_at' => $details['received_at'] ?? now(),
]);
} catch (UniqueConstraintViolationException) {
return null;
}
if ($kind === EmailBounceKind::Relay) {
// Kein Empfängerproblem — nur protokolliert, der Circuit Breaker
// zählt es separat.
return $bounce;
}
$this->applyBounce($key, $kind, $source, $bounce);
return $bounce;
}
/**
* Sperre von Hand oder aus einer Bounce-Liste (Import).
*/
public function suppress(string $email, EmailSuppressionReason $reason, string $source, ?string $note = null): ?EmailSuppression
{
$key = self::normalise($email);
if ($key === null) {
return null;
}
$suppression = EmailSuppression::query()->firstOrNew(['email' => $key]);
$wasActive = $suppression->exists && $suppression->isActive();
$suppression->fill([
'reason' => $reason,
'source' => $source,
'suppressed_at' => now(),
'released_at' => null,
'released_by_user_id' => null,
'note' => $note !== null ? mb_substr($note, 0, 500) : $suppression->note,
])->save();
if (! $wasActive) {
$this->activityLog->record(
ActivityType::MailSuppressed,
sprintf('%s gesperrt: %s (%s)', $key, $reason->label(), $source),
subject: $suppression,
actorLabel: $source,
);
}
return $suppression;
}
/**
* Freigabe im Admin, wenn jemand die Adresse repariert hat. Ein späterer
* Bounce setzt die Sperre neu.
*/
public function release(EmailSuppression $suppression, ?User $by = null, ?string $note = null): void
{
$suppression->forceFill([
'released_at' => now(),
'released_by_user_id' => $by?->getKey(),
'note' => $note !== null ? mb_substr($note, 0, 500) : $suppression->note,
])->save();
$this->activityLog->record(
ActivityType::MailSuppressed,
sprintf('%s freigegeben', $suppression->email),
user: $by,
subject: $suppression,
);
}
private function applyBounce(string $email, EmailBounceKind $kind, string $source, EmailBounce $bounce): void
{
$suppression = EmailSuppression::query()->firstOrNew(['email' => $email], [
'reason' => EmailSuppressionReason::HardBounce,
'source' => $source,
]);
$wasActive = $suppression->exists && $suppression->isActive();
$suppression->first_bounced_at ??= $bounce->received_at;
$suppression->last_bounced_at = $bounce->received_at;
if ($kind === EmailBounceKind::Hard) {
$suppression->hard_bounce_count++;
$suppression->reason = EmailSuppressionReason::HardBounce;
$suppression->source = $source;
$suppression->suppressed_at = now();
$suppression->released_at = null;
$suppression->released_by_user_id = null;
} else {
$suppression->soft_bounce_count++;
$recentSoft = EmailBounce::query()
->where('email', $email)
->where('kind', EmailBounceKind::Soft->value)
->where('received_at', '>=', now()->subDays((int) config('bounces.soft.window_days', 7)))
->count();
if (! $wasActive && $recentSoft >= (int) config('bounces.soft.threshold', 3)) {
$suppression->reason = EmailSuppressionReason::SoftBounces;
$suppression->source = $source;
$suppression->suppressed_at = now();
$suppression->released_at = null;
$suppression->released_by_user_id = null;
}
}
$suppression->save();
if (! $wasActive && $suppression->isActive()) {
$this->activityLog->record(
ActivityType::MailSuppressed,
sprintf('%s gesperrt: %s (%s%s)', $email, $suppression->reason->label(), $source, $bounce->status ? ', '.$bounce->status : ''),
subject: $suppression,
actorLabel: $source,
meta: array_filter(['reference' => $bounce->reference, 'diagnostic' => $bounce->diagnostic]),
);
}
}
}