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