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 0692d892e0
commit 35db30e019
15 changed files with 362 additions and 20 deletions

View file

@ -10,12 +10,20 @@ enum EmailSuppressionReason: string
case Manual = 'manual';
/**
* Abmeldung durch den Empfänger selbst (Roadmap 1d.3) — kein
* Zustellproblem. Bleibt für Benachrichtigungen wirksam, lässt aber
* durch, was die Person selbst anfordert (Anmeldelink).
*/
case Unsubscribed = 'unsubscribed';
public function label(): string
{
return match ($this) {
self::HardBounce => 'Postfach existiert nicht',
self::SoftBounces => 'wiederholt unzustellbar',
self::Manual => 'manuell gesperrt',
self::Unsubscribed => 'abgemeldet',
};
}
}

View file

@ -6,6 +6,8 @@ use App\Enums\ActivityType;
use App\Enums\EmailNotificationType;
use App\Models\User;
use App\Services\ActivityLog;
use App\Services\Company\NewsroomClaimService;
use App\Services\Mail\EmailSuppressionService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
@ -89,6 +91,75 @@ class EmailUnsubscribeController extends Controller
]);
}
/**
* Adressbezogene Abmeldung (Roadmap 1d.3): Empfänger ohne Konto. Die
* Sperrliste hält den Zustand; Anmeldelinks, die die Person selbst
* anfordert, gehen weiter durch (GateOutgoingMail).
*/
public function showAddress(Request $request, EmailSuppressionService $suppressions): View
{
$notificationType = $this->resolveType((string) $request->query('type', ''));
$email = EmailSuppressionService::normalise((string) $request->query('email', ''));
if (! $request->hasValidSignature() || $email === null) {
return view('web.email-unsubscribe', ['state' => 'invalid', 'notificationType' => $notificationType]);
}
$this->unsubscribeAddress($suppressions, $email, $notificationType, 'link');
return view('web.email-unsubscribe', [
'state' => 'unsubscribed',
'notificationType' => $notificationType,
'address' => NewsroomClaimService::mask($email),
'addressMode' => true,
'resubscribeUrl' => URL::signedRoute('email-preferences.resubscribe-address', ['email' => $email, 'type' => $notificationType->value]),
]);
}
public function oneClickUnsubscribeAddress(Request $request, EmailSuppressionService $suppressions): Response
{
abort_unless($request->hasValidSignature(), 403);
$email = EmailSuppressionService::normalise((string) $request->query('email', ''));
abort_if($email === null, 404);
$this->unsubscribeAddress($suppressions, $email, $this->resolveType((string) $request->query('type', '')), 'one-click');
return response('Sie wurden erfolgreich abgemeldet.');
}
public function resubscribeAddress(Request $request, EmailSuppressionService $suppressions): View
{
abort_unless($request->hasValidSignature(), 403);
$email = EmailSuppressionService::normalise((string) $request->query('email', ''));
abort_if($email === null, 404);
$notificationType = $this->resolveType((string) $request->query('type', ''));
if ($suppressions->resubscribe($email)) {
$this->activityLog->record(
ActivityType::EmailUnsubscribe,
sprintf('%s hat die Abmeldung zurückgenommen.', $email),
meta: ['email' => $email, 'action' => 'resubscribe'],
);
}
return view('web.email-unsubscribe', ['state' => 'resubscribed', 'notificationType' => $notificationType, 'addressMode' => true]);
}
private function unsubscribeAddress(EmailSuppressionService $suppressions, string $email, EmailNotificationType $notificationType, string $method): void
{
if ($suppressions->isSuppressed($email)) {
return;
}
$suppressions->unsubscribe($email, $method);
$this->activityLog->record(
ActivityType::EmailUnsubscribe,
sprintf('%s hat sich von Benachrichtigungen abgemeldet (%s).', $email, $notificationType->unsubscribeLabel()),
meta: ['email' => $email, 'type' => $notificationType->value, 'action' => 'unsubscribe', 'method' => $method],
);
}
private function optOut(User $user, EmailNotificationType $notificationType, string $method): void
{
if (! $user->wantsEmailNotification($notificationType)) {

View file

@ -4,6 +4,7 @@ namespace App\Listeners;
use App\Enums\ActivityType;
use App\Exceptions\MailDeliveryPausedException;
use App\Mail\MagicLoginLink;
use App\Mail\OperationalAlertMail;
use App\Services\ActivityLog;
use App\Services\Mail\EmailSuppressionService;
@ -30,6 +31,9 @@ use Symfony\Component\Mime\Address;
*/
class GateOutgoingMail
{
/** Mails, die der Empfänger selbst auslöst — eine Abmeldung hält sie nicht auf. */
private const REQUESTED_BY_RECIPIENT = [MagicLoginLink::class];
public function __construct(
private readonly EmailSuppressionService $suppressions,
private readonly MailCircuitBreaker $breaker,
@ -66,7 +70,10 @@ class GateOutgoingMail
$to = $this->addresses($message->getTo());
$cc = $this->addresses($message->getCc());
$bcc = $this->addresses($message->getBcc());
$suppressed = $this->suppressions->suppressedAmong([...$to, ...$cc, ...$bcc]);
// Eine Abmeldung (1d.3) gilt für Benachrichtigungen — nicht für den
// Anmeldelink, den die Person gerade selbst angefordert hat.
$requested = in_array($mailable, self::REQUESTED_BY_RECIPIENT, true);
$suppressed = $this->suppressions->suppressedAmong([...$to, ...$cc, ...$bcc], includeUnsubscribed: ! $requested);
if ($suppressed === []) {
return true;

View file

@ -32,6 +32,20 @@ trait Unsubscribable
]);
}
/**
* Abmeldung für Empfänger ohne Konto (Roadmap 1d.3): Pressekontakt oder
* Firmen-E-Mail einer API-Meldung. Der Link trägt die Adresse selbst —
* ein Konto-Link würde den Einlieferer abmelden, nicht den Empfänger.
*/
protected function configureAddressUnsubscribe(string $email, EmailNotificationType $type): void
{
$this->notificationType = $type;
$this->unsubscribeUrl = URL::signedRoute('email-preferences.unsubscribe-address', [
'email' => mb_strtolower(trim($email)),
'type' => $type->value,
]);
}
public function headers(): Headers
{
return new Headers(text: array_filter([

View file

@ -43,6 +43,7 @@ class PressReleasePublished extends Mailable
public readonly User $user,
public readonly PressRelease $pressRelease,
?string $recipientName = null,
?string $recipientEmail = null,
) {
$this->recipientName = $recipientName ?? $user->name;
$this->showUrl = self::tagged(
@ -62,7 +63,13 @@ class PressReleasePublished extends Mailable
$this->manageNewsroomUrl = $this->manageNewsroomUrl($pressRelease);
$this->configureUnsubscribe($user, EmailNotificationType::PressReleasePublished);
// Geht die Mail an einen Empfänger ohne Konto (API-Meldung), meldet
// der Abmeldelink diese Adresse ab — nicht das Konto des Einlieferers.
if ($recipientEmail !== null && mb_strtolower(trim($recipientEmail)) !== mb_strtolower((string) $user->email)) {
$this->configureAddressUnsubscribe($recipientEmail, EmailNotificationType::PressReleasePublished);
} else {
$this->configureUnsubscribe($user, EmailNotificationType::PressReleasePublished);
}
}
/**

View file

@ -34,6 +34,7 @@ class PressReleaseRejected extends Mailable
public readonly PressRelease $pressRelease,
public readonly ?string $reason = null,
?string $recipientName = null,
?string $recipientEmail = null,
) {
$this->recipientName = $recipientName ?? $user->name;
$editUrl = $user->canAccessAdmin()
@ -49,7 +50,13 @@ class PressReleaseRejected extends Mailable
$this->helpUrl = $this->helpUrl($pressRelease);
$this->configureUnsubscribe($user, EmailNotificationType::PressReleaseRejected);
// Geht die Mail an einen Empfänger ohne Konto (API-Meldung), meldet
// der Abmeldelink diese Adresse ab — nicht das Konto des Einlieferers.
if ($recipientEmail !== null && mb_strtolower(trim($recipientEmail)) !== mb_strtolower((string) $user->email)) {
$this->configureAddressUnsubscribe($recipientEmail, EmailNotificationType::PressReleaseRejected);
} else {
$this->configureUnsubscribe($user, EmailNotificationType::PressReleaseRejected);
}
}
/**

View file

@ -66,7 +66,9 @@ class NewsroomClaimService
return collect();
}
$suppressed = $this->suppressions->suppressedAmong($candidates);
// Abgemeldete Adressen (1d.3) bleiben beanspruchbar — den Anmeldelink
// fordert die Person selbst an.
$suppressed = $this->suppressions->suppressedAmong($candidates, includeUnsubscribed: false);
return collect(array_values(array_filter($candidates, fn (string $email): bool => ! in_array($email, $suppressed, true))));
}

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;
}
/**

View file

@ -794,8 +794,8 @@ class PressReleaseService
}
$mailable = $notificationType === EmailNotificationType::PressReleasePublished
? new PressReleasePublished($user, $pressRelease, $recipientName)
: new PressReleaseRejected($user, $pressRelease, $reason, $recipientName);
? new PressReleasePublished($user, $pressRelease, $recipientName, $recipient)
: new PressReleaseRejected($user, $pressRelease, $reason, $recipientName, $recipient);
Mail::to($recipient)->queue($mailable);
}