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
87f95fb7a7
commit
0a658bb34b
25 changed files with 1312 additions and 4 deletions
212
app/Services/Mail/EmailSuppressionService.php
Normal file
212
app/Services/Mail/EmailSuppressionService.php
Normal 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]),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue