presseportale/app/Services/Mail/EmailSuppressionService.php
Kevin Adametz 9380937562 mail: adressbezogene Abmeldung fuer Empfaenger ohne Konto (Roadmap 1d.3)
Der Abmeldelink in API-Benachrichtigungen zeigte auf das Konto des
Einlieferers - ein Klick des Pressekontakts haette connektar abgemeldet.
Jetzt traegt der Link die Empfaengeradresse (signiert), der Zustand liegt
als Unsubscribed in der Sperrliste. Anmeldelinks, die die Person selbst
anfordert, gehen weiter durch; harte Bounces sperren alles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 14:35:47 +00:00

269 lines
9.8 KiB
PHP

<?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;
}
/**
* @param bool $includeUnsubscribed false = nur Zustellprobleme zählen;
* eine Abmeldung (1d.3) hält
* Benachrichtigungen ab, aber nicht,
* was die Person selbst anfordert
*/
public function isSuppressed(?string $email, bool $includeUnsubscribed = true): bool
{
$key = self::normalise($email);
if ($key === null) {
return false;
}
return EmailSuppression::query()
->active()
->where('email', $key)
->when(! $includeUnsubscribed, fn ($q) => $q->where('reason', '!=', EmailSuppressionReason::Unsubscribed->value))
->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, bool $includeUnsubscribed = true): array
{
$keys = array_values(array_filter(array_map(self::normalise(...), $emails)));
if ($keys === []) {
return [];
}
return EmailSuppression::query()
->active()
->whereIn('email', $keys)
->when(! $includeUnsubscribed, fn ($q) => $q->where('reason', '!=', EmailSuppressionReason::Unsubscribed->value))
->pluck('email')
->all();
}
/**
* Abmeldung durch den Empfänger (Roadmap 1d.3): für Adressen ohne Konto
* — Pressekontakt, Firmen-E-Mail — ist die Sperrliste der einzige Ort,
* an dem „will keine Benachrichtigungen" stehen kann. Eine bestehende
* Zustellsperre bleibt, was sie ist.
*/
public function unsubscribe(string $email, string $method = 'link'): ?EmailSuppression
{
$key = self::normalise($email);
if ($key === null) {
return null;
}
$existing = EmailSuppression::query()->active()->where('email', $key)->first();
if ($existing !== null && $existing->reason !== EmailSuppressionReason::Unsubscribed) {
return $existing;
}
return $this->suppress($key, EmailSuppressionReason::Unsubscribed, 'unsubscribe', 'Abmeldung durch Empfänger ('.$method.')');
}
/**
* Abmeldung zurücknehmen — nur eine Abmeldung; Zustellsperren löst nur
* der Admin.
*/
public function resubscribe(string $email): bool
{
$key = self::normalise($email);
$suppression = $key === null ? null : EmailSuppression::query()->active()->where('email', $key)->where('reason', EmailSuppressionReason::Unsubscribed->value)->first();
if ($suppression === null) {
return false;
}
$this->release($suppression, null, 'Abmeldung durch Empfänger zurückgenommen');
return true;
}
/**
* 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 || $kind === EmailBounceKind::Policy) {
// Kein Empfängerproblem — nur protokolliert. Relay zählt der
// Circuit Breaker separat, Richtlinien-Ablehnungen tauchen im
// Einlieferer-Bericht auf und sind Sache eines Menschen.
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]),
);
}
}
}