From 35db30e019d609a29f0743d326f6d446a9cd25b1 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Wed, 16 Sep 2026 14:35:47 +0000 Subject: [PATCH] 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) --- app/Enums/EmailSuppressionReason.php | 8 ++ .../EmailUnsubscribeController.php | 71 +++++++++++ app/Listeners/GateOutgoingMail.php | 9 +- app/Mail/Concerns/Unsubscribable.php | 14 +++ app/Mail/PressReleasePublished.php | 9 +- app/Mail/PressReleaseRejected.php | 9 +- app/Services/Company/NewsroomClaimService.php | 4 +- app/Services/Mail/EmailSuppressionService.php | 64 +++++++++- .../PressRelease/PressReleaseService.php | 4 +- bootstrap/app.php | 1 + dev/2026-09-01/roadmap-2026.md | 2 +- dev/2026-09-01/umsetzung-protokoll.md | 28 +++++ .../views/web/email-unsubscribe.blade.php | 35 ++++-- routes/customer.php | 11 ++ tests/Feature/AddressUnsubscribeTest.php | 113 ++++++++++++++++++ 15 files changed, 362 insertions(+), 20 deletions(-) create mode 100644 tests/Feature/AddressUnsubscribeTest.php diff --git a/app/Enums/EmailSuppressionReason.php b/app/Enums/EmailSuppressionReason.php index c04c49a3..4cdf6a04 100644 --- a/app/Enums/EmailSuppressionReason.php +++ b/app/Enums/EmailSuppressionReason.php @@ -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', }; } } diff --git a/app/Http/Controllers/EmailUnsubscribeController.php b/app/Http/Controllers/EmailUnsubscribeController.php index f2236afe..f323bdb2 100644 --- a/app/Http/Controllers/EmailUnsubscribeController.php +++ b/app/Http/Controllers/EmailUnsubscribeController.php @@ -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)) { diff --git a/app/Listeners/GateOutgoingMail.php b/app/Listeners/GateOutgoingMail.php index 43b8301c..9b4a8ee5 100644 --- a/app/Listeners/GateOutgoingMail.php +++ b/app/Listeners/GateOutgoingMail.php @@ -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; diff --git a/app/Mail/Concerns/Unsubscribable.php b/app/Mail/Concerns/Unsubscribable.php index 7c24896d..0e01e33c 100644 --- a/app/Mail/Concerns/Unsubscribable.php +++ b/app/Mail/Concerns/Unsubscribable.php @@ -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([ diff --git a/app/Mail/PressReleasePublished.php b/app/Mail/PressReleasePublished.php index bac1e872..764c5310 100644 --- a/app/Mail/PressReleasePublished.php +++ b/app/Mail/PressReleasePublished.php @@ -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); + } } /** diff --git a/app/Mail/PressReleaseRejected.php b/app/Mail/PressReleaseRejected.php index 138dfa5d..78630327 100644 --- a/app/Mail/PressReleaseRejected.php +++ b/app/Mail/PressReleaseRejected.php @@ -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); + } } /** diff --git a/app/Services/Company/NewsroomClaimService.php b/app/Services/Company/NewsroomClaimService.php index c1b9e2dc..7cb2d6bd 100644 --- a/app/Services/Company/NewsroomClaimService.php +++ b/app/Services/Company/NewsroomClaimService.php @@ -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)))); } diff --git a/app/Services/Mail/EmailSuppressionService.php b/app/Services/Mail/EmailSuppressionService.php index 96911ecf..b5bcd453 100644 --- a/app/Services/Mail/EmailSuppressionService.php +++ b/app/Services/Mail/EmailSuppressionService.php @@ -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 $emails * @return list 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; } /** diff --git a/app/Services/PressRelease/PressReleaseService.php b/app/Services/PressRelease/PressReleaseService.php index 59d4ed30..d834ae0e 100644 --- a/app/Services/PressRelease/PressReleaseService.php +++ b/app/Services/PressRelease/PressReleaseService.php @@ -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); } diff --git a/bootstrap/app.php b/bootstrap/app.php index 8a202b07..b0842eb6 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -77,6 +77,7 @@ return Application::configure(basePath: dirname(__DIR__)) // CSRF-Schutz. $middleware->validateCsrfTokens(except: [ 'e-mail-benachrichtigungen/abmelden/*', + 'e-mail-benachrichtigungen/abmelden-adresse', ]); $middleware->alias([ diff --git a/dev/2026-09-01/roadmap-2026.md b/dev/2026-09-01/roadmap-2026.md index 4c0f6027..63b02cf6 100644 --- a/dev/2026-09-01/roadmap-2026.md +++ b/dev/2026-09-01/roadmap-2026.md @@ -146,7 +146,7 @@ Für selbst angelegte Firmendaten trägt der Nutzer die Verantwortung. Für die |---|---|---|---| | ~~1d.1~~ | Hilfeseite ohne kommerziellen Anteil — **gebaut 16.09.2026**: `/{edition}/gute-pressemitteilung` (DE/EN), Anlass, Aufbau, Sprache, Prüfablauf, sieben Qualitätsdimensionen offen, Pressebilder; Sitemap + Hilfe-Center | S | keine | | ~~1d.2~~ | Textbaustein in die Ablehnungsmail — **16.09.2026**: Link auf dem Portal der Meldung, Herkunft `mail_reject`, nichts Kommerzielles (E6) | S | 1d.1 | -| 1d.3 | Abmeldemöglichkeit für Benachrichtigungen, Status in der Suppression-Logik geführt | S | Bounce-Suppression live | +| ~~1d.3~~ | Abmeldemöglichkeit für Benachrichtigungen — **16.09.2026**: für Empfänger ohne Konto (Pressekontakt, Firmen-E-Mail) adressbezogener Abmeldelink, Zustand als `Unsubscribed` in der Sperrliste; selbst angeforderte Anmeldelinks gehen weiter durch. Vorher zeigte der Link in API-Benachrichtigungen auf das Konto des Einlieferers | S | Bounce-Suppression live | | ~~1d.4~~ | Prüfwerkzeug — **E3 entschieden 16.09.2026: kein eigenständiges Werkzeug.** Prüfung nur beim Freigeben, Ergebnis am Entwurf; „Zur Prüfung freigeben" mit Erklärung, was danach passiert; automatische Prüfung je Meldung `max_automatic_attempts` (3), danach geblockt in die Admin-Queue („Prüfung nötig"), Freischalten oder Kommentar, Automatik für die Meldung ab dann aus | M | 1d.1 | | ~~1d.5~~ | Bewertung einer bestehenden Meldung über Magic Link — **mit 1c.2 erledigt**: der Beansprucher sieht Ablehnungsgrund, KI-Stufe und Content-Score auf der Meldungsseite | S | 1d.4, 1b.2 | | ~~1d.6~~ | Partnerbereich am Seitenende als BALD — **16.09.2026** am Ende von 1d.1 | S | 1d.1 | diff --git a/dev/2026-09-01/umsetzung-protokoll.md b/dev/2026-09-01/umsetzung-protokoll.md index c742c69c..9e997393 100644 --- a/dev/2026-09-01/umsetzung-protokoll.md +++ b/dev/2026-09-01/umsetzung-protokoll.md @@ -1406,3 +1406,31 @@ Beansprucher sieht Ablehnungsgrund, KI-Stufe, Content-Score). Deploy: `migrate --force` (Millisekunden), `view:cache`, `queue:restart`. Kein Backfill — bestehende Meldungen starten mit 0 Versuchen. + +## 1d.3 — Abmeldung für Empfänger ohne Konto + +**Befund:** Die Benachrichtigungen zu API-Meldungen gehen an Pressekontakt +oder Firmen-E-Mail — der Abmeldelink darin war aber für das *Konto des +Einlieferers* signiert (`configureUnsubscribe($user, …)`). Ein Klick des +Pressekontakts hätte connektar abgemeldet, nicht sich selbst. + +**Umsetzung:** `EmailSuppressionReason::Unsubscribed` als dritter Zustand +in der Sperrliste. Mailables bekommen den tatsächlichen Empfänger +(`$recipientEmail`); weicht er vom Konto ab, trägt der Link die Adresse +(`/e-mail-benachrichtigungen/abmelden-adresse?email=…&type=…`, signiert, +One-Click-POST ohne CSRF wie bisher). Die Abmeldung gilt für alle +Benachrichtigungen an die Adresse; Rücknahme von der Bestätigungsseite. +Eine bestehende Zustellsperre wird nicht zur Abmeldung herabgestuft, und +eine Abmeldung löst nur der Empfänger selbst. + +**Ausnahme im Gate:** `GateOutgoingMail` lässt `MagicLoginLink` an +abgemeldete Adressen durch (`suppressedAmong(…, includeUnsubscribed: +false)`), die Claim-Seite behandelt sie als erreichbar — den Anmeldelink +fordert die Person selbst an. Harte Bounces sperren weiterhin alles. + +Tests: `AddressUnsubscribeTest` (5). Admin ▸ Mail-Sperrliste zeigt den +Grund „abgemeldet" ohne weitere Änderung. + +**Damit ist Block 1d komplett.** Offen aus dem Block: ob der Autor beim +Block nach dem N-ten Rot eine kurze Mail bekommen soll („ein Mensch +prüft"); heute sieht er es nur im Konto. diff --git a/resources/views/web/email-unsubscribe.blade.php b/resources/views/web/email-unsubscribe.blade.php index 08e8b7f4..0973852a 100644 --- a/resources/views/web/email-unsubscribe.blade.php +++ b/resources/views/web/email-unsubscribe.blade.php @@ -21,14 +21,27 @@ @if ($state === 'unsubscribed')
Abmeldung bestätigt
-

- Sie erhalten keine E-Mails vom Typ „{{ $notificationType->label() }}" mehr. -

-

- Die Abmeldung gilt nur für {{ $notificationType->unsubscribeLabel() }}. - System-E-Mails wie Passwort-Zurücksetzen oder rechtlich erforderliche - Bestätigungen erhalten Sie weiterhin. -

+ @if ($addressMode ?? false) + {{-- Roadmap 1d.3: Empfänger ohne Konto — die Abmeldung gilt + für die Adresse und alle Benachrichtigungen. --}} +

+ An {{ $address }} gehen keine Benachrichtigungen mehr. +

+

+ Die Abmeldung gilt für alle Benachrichtigungen zu Pressemitteilungen an diese Adresse. + Einen Anmeldelink, den Sie selbst anfordern — etwa um den Newsroom Ihrer Firma zu + verwalten —, erhalten Sie weiterhin. +

+ @else +

+ Sie erhalten keine E-Mails vom Typ „{{ $notificationType->label() }}" mehr. +

+

+ Die Abmeldung gilt nur für {{ $notificationType->unsubscribeLabel() }}. + System-E-Mails wie Passwort-Zurücksetzen oder rechtlich erforderliche + Bestätigungen erhalten Sie weiterhin. +

+ @endif
@csrf