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>
This commit is contained in:
Kevin Adametz 2026-09-16 14:35:47 +00:00
parent 13716b9c06
commit 9380937562
15 changed files with 362 additions and 20 deletions

View file

@ -35,7 +35,13 @@ class EmailSuppressionService
return $email === '' ? null : $email;
}
public function isSuppressed(?string $email): bool
/**
* @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);
@ -43,7 +49,11 @@ class EmailSuppressionService
return false;
}
return EmailSuppression::query()->active()->where('email', $key)->exists();
return EmailSuppression::query()
->active()
->where('email', $key)
->when(! $includeUnsubscribed, fn ($q) => $q->where('reason', '!=', EmailSuppressionReason::Unsubscribed->value))
->exists();
}
/**
@ -53,7 +63,7 @@ class EmailSuppressionService
* @param list<string> $emails
* @return list<string> normalisiert
*/
public function suppressedAmong(array $emails): array
public function suppressedAmong(array $emails, bool $includeUnsubscribed = true): array
{
$keys = array_values(array_filter(array_map(self::normalise(...), $emails)));
@ -61,7 +71,53 @@ class EmailSuppressionService
return [];
}
return EmailSuppression::query()->active()->whereIn('email', $keys)->pluck('email')->all();
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;
}
/**