From 51da3741738b05fa04d19ba3ded4c1a638a7651f Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Fri, 10 Jul 2026 17:15:05 +0000 Subject: [PATCH] E-Mail-Praeferenzen: Opt-out je Mail-Typ + One-Click-Unsubscribe ohne Login Co-authored-by: Cursor --- app/Enums/EmailNotificationType.php | 65 ++++++ .../EmailUnsubscribeController.php | 83 ++++++++ app/Listeners/ProcessStripeWebhook.php | 3 +- app/Mail/Concerns/Unsubscribable.php | 42 ++++ app/Mail/PressReleaseDepublished.php | 6 +- app/Mail/PressReleasePublished.php | 6 +- app/Mail/PressReleaseRejected.php | 6 +- app/Models/EmailNotificationOptOut.php | 40 ++++ app/Models/User.php | 28 +++ bootstrap/app.php | 7 + .../EmailNotificationOptOutFactory.php | 25 +++ ...eate_email_notification_opt_outs_table.php | 31 +++ .../emails/unsubscribe-note.blade.php | 22 ++ .../press-release/depublished.blade.php | 2 + .../emails/press-release/published.blade.php | 19 +- .../emails/press-release/rejected.blade.php | 2 + .../views/livewire/customer/profile.blade.php | 53 ++++- .../views/web/email-unsubscribe.blade.php | 71 +++++++ routes/customer.php | 21 ++ .../EmailNotificationPreferencesTest.php | 199 ++++++++++++++++++ tests/Feature/Mail/EmailBrandingTest.php | 24 ++- 21 files changed, 746 insertions(+), 9 deletions(-) create mode 100644 app/Enums/EmailNotificationType.php create mode 100644 app/Http/Controllers/EmailUnsubscribeController.php create mode 100644 app/Mail/Concerns/Unsubscribable.php create mode 100644 app/Models/EmailNotificationOptOut.php create mode 100644 database/factories/EmailNotificationOptOutFactory.php create mode 100644 database/migrations/2026_07_10_154103_create_email_notification_opt_outs_table.php create mode 100644 resources/views/components/emails/unsubscribe-note.blade.php create mode 100644 resources/views/web/email-unsubscribe.blade.php create mode 100644 tests/Feature/EmailNotificationPreferencesTest.php diff --git a/app/Enums/EmailNotificationType.php b/app/Enums/EmailNotificationType.php new file mode 100644 index 00000000..f82187ee --- /dev/null +++ b/app/Enums/EmailNotificationType.php @@ -0,0 +1,65 @@ + 'Pressemitteilung veröffentlicht', + self::PressReleaseRejected => 'Pressemitteilung abgelehnt', + self::PressReleaseDepublished => 'Pressemitteilung depubliziert', + }; + } + + /** + * Beschreibung für die Präferenzen-Einstellung im Kundenbereich. + */ + public function settingsDescription(): string + { + return match ($this) { + self::PressReleasePublished => 'Benachrichtigung, sobald eine von Ihnen eingereichte Pressemitteilung geprüft und veröffentlicht wurde.', + self::PressReleaseRejected => 'Benachrichtigung, wenn eine eingereichte Pressemitteilung abgelehnt wurde — inklusive Ablehnungsgrund.', + self::PressReleaseDepublished => 'Bestätigung, wenn eine beauftragte Depublikation durchgeführt wurde.', + }; + } + + /** + * „Warum erhalten Sie diese E-Mail?"-Begründung im Mail-Footer. + */ + public function mailReason(): string + { + return match ($this) { + self::PressReleasePublished => 'Sie wurden als Pressekontakt hinterlegt und werden daher über die Veröffentlichung Ihrer Pressemitteilungen informiert.', + self::PressReleaseRejected => 'Sie wurden als Pressekontakt hinterlegt und werden daher über die Ablehnung Ihrer eingereichten Pressemitteilungen informiert.', + self::PressReleaseDepublished => 'Sie haben die Depublikation dieser Pressemitteilung beauftragt und erhalten daher diese Bestätigung.', + }; + } + + /** + * Was der Empfänger mit dem Abmelde-Link abbestellt (Mail-Footer). + */ + public function unsubscribeLabel(): string + { + return match ($this) { + self::PressReleasePublished => 'Benachrichtigungen über das Veröffentlichen Ihrer Pressemitteilungen', + self::PressReleaseRejected => 'Benachrichtigungen über die Ablehnung Ihrer Pressemitteilungen', + self::PressReleaseDepublished => 'Bestätigungen über die Depublikation Ihrer Pressemitteilungen', + }; + } +} diff --git a/app/Http/Controllers/EmailUnsubscribeController.php b/app/Http/Controllers/EmailUnsubscribeController.php new file mode 100644 index 00000000..dca120b6 --- /dev/null +++ b/app/Http/Controllers/EmailUnsubscribeController.php @@ -0,0 +1,83 @@ +resolveType($type); + + if (! $request->hasValidSignature()) { + return view('web.email-unsubscribe', [ + 'state' => 'invalid', + 'notificationType' => $notificationType, + ]); + } + + $user->optOutOfEmailNotification($notificationType); + + return view('web.email-unsubscribe', [ + 'state' => 'unsubscribed', + 'notificationType' => $notificationType, + 'resubscribeUrl' => URL::signedRoute('email-preferences.resubscribe', [ + 'user' => $user->id, + 'type' => $notificationType->value, + ]), + ]); + } + + /** + * RFC-8058-Endpunkt: Mail-Clients senden `List-Unsubscribe=One-Click` + * per POST, ohne die Seite zu öffnen. Antwort bewusst schlicht (2xx). + */ + public function oneClickUnsubscribe(Request $request, User $user, string $type): Response + { + abort_unless($request->hasValidSignature(), 403); + + $user->optOutOfEmailNotification($this->resolveType($type)); + + return response('Sie wurden erfolgreich abgemeldet.'); + } + + public function resubscribe(Request $request, User $user, string $type): View|RedirectResponse + { + abort_unless($request->hasValidSignature(), 403); + + $notificationType = $this->resolveType($type); + + $user->optIntoEmailNotification($notificationType); + + return view('web.email-unsubscribe', [ + 'state' => 'resubscribed', + 'notificationType' => $notificationType, + ]); + } + + private function resolveType(string $type): EmailNotificationType + { + $notificationType = EmailNotificationType::tryFrom($type); + + abort_if($notificationType === null, 404); + + return $notificationType; + } +} diff --git a/app/Listeners/ProcessStripeWebhook.php b/app/Listeners/ProcessStripeWebhook.php index 1346e6e7..1192cdc7 100644 --- a/app/Listeners/ProcessStripeWebhook.php +++ b/app/Listeners/ProcessStripeWebhook.php @@ -2,6 +2,7 @@ namespace App\Listeners; +use App\Enums\EmailNotificationType; use App\Enums\InvoiceStatus; use App\Enums\PressReleaseStatus; use App\Enums\SinglePurchaseStatus; @@ -279,7 +280,7 @@ class ProcessStripeWebhook // Kauf kann von einem Firmen-Kollegen mit Zugriff stammen). $buyer = $purchase->user; - if ($buyer && $buyer->email) { + if ($buyer && $buyer->email && $buyer->wantsEmailNotification(EmailNotificationType::PressReleaseDepublished)) { Mail::to($buyer->email)->queue(new PressReleaseDepublished($buyer, $pressRelease)); } diff --git a/app/Mail/Concerns/Unsubscribable.php b/app/Mail/Concerns/Unsubscribable.php new file mode 100644 index 00000000..75cf1483 --- /dev/null +++ b/app/Mail/Concerns/Unsubscribable.php @@ -0,0 +1,42 @@ +notificationType = $type; + $this->unsubscribeUrl = URL::signedRoute('email-preferences.unsubscribe', [ + 'user' => $user->id, + 'type' => $type->value, + ]); + } + + public function headers(): Headers + { + return new Headers(text: [ + 'List-Unsubscribe' => '<'.$this->unsubscribeUrl.'>', + 'List-Unsubscribe-Post' => 'List-Unsubscribe=One-Click', + ]); + } +} diff --git a/app/Mail/PressReleaseDepublished.php b/app/Mail/PressReleaseDepublished.php index 5a8d33e9..74ce9dc3 100644 --- a/app/Mail/PressReleaseDepublished.php +++ b/app/Mail/PressReleaseDepublished.php @@ -2,6 +2,8 @@ namespace App\Mail; +use App\Enums\EmailNotificationType; +use App\Mail\Concerns\Unsubscribable; use App\Models\PressRelease; use App\Models\User; use Illuminate\Bus\Queueable; @@ -17,7 +19,7 @@ use Illuminate\Queue\SerializesModels; */ class PressReleaseDepublished extends Mailable { - use Queueable, SerializesModels; + use Queueable, SerializesModels, Unsubscribable; public string $showUrl; @@ -28,6 +30,8 @@ class PressReleaseDepublished extends Mailable $this->showUrl = $user->canAccessAdmin() ? route('admin.press-releases.show', $pressRelease->id) : route('me.press-releases.show', $pressRelease->id); + + $this->configureUnsubscribe($user, EmailNotificationType::PressReleaseDepublished); } public function envelope(): Envelope diff --git a/app/Mail/PressReleasePublished.php b/app/Mail/PressReleasePublished.php index 39cb935f..d5a5d40d 100644 --- a/app/Mail/PressReleasePublished.php +++ b/app/Mail/PressReleasePublished.php @@ -2,6 +2,8 @@ namespace App\Mail; +use App\Enums\EmailNotificationType; +use App\Mail\Concerns\Unsubscribable; use App\Models\PressRelease; use App\Models\User; use Illuminate\Bus\Queueable; @@ -12,7 +14,7 @@ use Illuminate\Queue\SerializesModels; class PressReleasePublished extends Mailable { - use Queueable, SerializesModels; + use Queueable, SerializesModels, Unsubscribable; public string $showUrl; @@ -33,6 +35,8 @@ class PressReleasePublished extends Mailable : route('me.press-releases.show', $pressRelease->id); $this->portalLinks = $pressRelease->publicPortalLinks(); + + $this->configureUnsubscribe($user, EmailNotificationType::PressReleasePublished); } public function envelope(): Envelope diff --git a/app/Mail/PressReleaseRejected.php b/app/Mail/PressReleaseRejected.php index bc04fe91..0c9641aa 100644 --- a/app/Mail/PressReleaseRejected.php +++ b/app/Mail/PressReleaseRejected.php @@ -2,6 +2,8 @@ namespace App\Mail; +use App\Enums\EmailNotificationType; +use App\Mail\Concerns\Unsubscribable; use App\Models\PressRelease; use App\Models\User; use Illuminate\Bus\Queueable; @@ -12,7 +14,7 @@ use Illuminate\Queue\SerializesModels; class PressReleaseRejected extends Mailable { - use Queueable, SerializesModels; + use Queueable, SerializesModels, Unsubscribable; public string $editUrl; @@ -24,6 +26,8 @@ class PressReleaseRejected extends Mailable $this->editUrl = $user->canAccessAdmin() ? route('admin.press-releases.edit', $pressRelease->id) : route('me.press-releases.edit', $pressRelease->id); + + $this->configureUnsubscribe($user, EmailNotificationType::PressReleaseRejected); } public function envelope(): Envelope diff --git a/app/Models/EmailNotificationOptOut.php b/app/Models/EmailNotificationOptOut.php new file mode 100644 index 00000000..34195bf5 --- /dev/null +++ b/app/Models/EmailNotificationOptOut.php @@ -0,0 +1,40 @@ + */ + use HasFactory; + + protected $fillable = [ + 'user_id', + 'type', + ]; + + /** + * @return array + */ + protected function casts(): array + { + return [ + 'type' => EmailNotificationType::class, + ]; + } + + public function user(): BelongsTo + { + return $this->belongsTo(User::class); + } +} diff --git a/app/Models/User.php b/app/Models/User.php index 1fa4936a..8c14e7f0 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -2,6 +2,7 @@ namespace App\Models; +use App\Enums\EmailNotificationType; use App\Enums\Portal; use App\Enums\RegistrationType; use App\Enums\Tier; @@ -367,6 +368,33 @@ class User extends Authenticatable implements MustVerifyEmail return $this->hasMany(NewsletterSubscription::class); } + public function emailNotificationOptOuts(): HasMany + { + return $this->hasMany(EmailNotificationOptOut::class); + } + + /** + * Versand-Gate für Benachrichtigungs-Mails: Vor jedem Versand eines + * abbestellbaren Mail-Typs prüfen, ob der Empfänger ihn abbestellt hat + * (One-Click-Unsubscribe aus der Mail oder Präferenzen im Profil). + */ + public function wantsEmailNotification(EmailNotificationType $type): bool + { + return ! $this->emailNotificationOptOuts() + ->where('type', $type->value) + ->exists(); + } + + public function optOutOfEmailNotification(EmailNotificationType $type): void + { + $this->emailNotificationOptOuts()->firstOrCreate(['type' => $type->value]); + } + + public function optIntoEmailNotification(EmailNotificationType $type): void + { + $this->emailNotificationOptOuts()->where('type', $type->value)->delete(); + } + public function billingAddress(): HasOne { return $this->hasOne(BillingAddress::class); diff --git a/bootstrap/app.php b/bootstrap/app.php index 1d89694e..a95acbe4 100644 --- a/bootstrap/app.php +++ b/bootstrap/app.php @@ -59,6 +59,13 @@ return Application::configure(basePath: dirname(__DIR__)) RejectLegacyApiKeys::class, ]); + // One-Click-Unsubscribe (RFC 8058): Mail-Clients POSTen ohne Session/ + // CSRF-Token auf die signierte Abmelde-URL — die Signatur ersetzt den + // CSRF-Schutz. + $middleware->validateCsrfTokens(except: [ + 'e-mail-benachrichtigungen/abmelden/*', + ]); + $middleware->alias([ 'abilities' => CheckAbilities::class, 'ability' => CheckForAnyAbility::class, diff --git a/database/factories/EmailNotificationOptOutFactory.php b/database/factories/EmailNotificationOptOutFactory.php new file mode 100644 index 00000000..f420b497 --- /dev/null +++ b/database/factories/EmailNotificationOptOutFactory.php @@ -0,0 +1,25 @@ + + */ +class EmailNotificationOptOutFactory extends Factory +{ + /** + * @return array + */ + public function definition(): array + { + return [ + 'user_id' => User::factory(), + 'type' => fake()->randomElement(EmailNotificationType::cases()), + ]; + } +} diff --git a/database/migrations/2026_07_10_154103_create_email_notification_opt_outs_table.php b/database/migrations/2026_07_10_154103_create_email_notification_opt_outs_table.php new file mode 100644 index 00000000..84fcf9f3 --- /dev/null +++ b/database/migrations/2026_07_10_154103_create_email_notification_opt_outs_table.php @@ -0,0 +1,31 @@ +id(); + $table->foreignId('user_id')->constrained()->cascadeOnDelete(); + $table->string('type', 64); + $table->timestamps(); + + $table->unique(['user_id', 'type']); + }); + } + + /** + * Reverse the migrations. + */ + public function down(): void + { + Schema::dropIfExists('email_notification_opt_outs'); + } +}; diff --git a/resources/views/components/emails/unsubscribe-note.blade.php b/resources/views/components/emails/unsubscribe-note.blade.php new file mode 100644 index 00000000..072fd9eb --- /dev/null +++ b/resources/views/components/emails/unsubscribe-note.blade.php @@ -0,0 +1,22 @@ +@props([ + /** @var \App\Enums\EmailNotificationType */ + 'type', + 'url', +]) + +{{-- + Rechtlicher Hinweis unter Benachrichtigungs-Mails: erklärt, warum der + Empfänger die Mail erhält, und verlinkt die One-Click-Abmeldung (signierte + URL, ohne Login). Gehört unter jede abbestellbare Benachrichtigungs-Mail — + nicht unter System-Mails (Passwort, Magic-Link etc.). +--}} +
+

+ Warum erhalten Sie diese E-Mail? + {{ $type->mailReason() }} + Wenn Sie keine {{ $type->unsubscribeLabel() }} mehr erhalten möchten, + können Sie sich hier mit einem Klick austragen. + Ihre E-Mail-Benachrichtigungen verwalten Sie außerdem jederzeit in Ihrem + Konto unter „Mein Profil". +

+
diff --git a/resources/views/emails/press-release/depublished.blade.php b/resources/views/emails/press-release/depublished.blade.php index 38e97d0b..03bc98ca 100644 --- a/resources/views/emails/press-release/depublished.blade.php +++ b/resources/views/emails/press-release/depublished.blade.php @@ -18,4 +18,6 @@

Unter der bisherigen Adresse erscheint nur noch der Hinweis, dass die Pressemitteilung auf Wunsch des Absenders entfernt wurde — Inhalte sind dort nicht mehr sichtbar. Die Rechnung zu diesem Auftrag finden Sie in Ihrem Konto unter Rechnungen.

Bei Fragen erreichen Sie uns über den Hilfe-Bereich auf pressekonto.

+ + diff --git a/resources/views/emails/press-release/published.blade.php b/resources/views/emails/press-release/published.blade.php index bde9c394..5d30f035 100644 --- a/resources/views/emails/press-release/published.blade.php +++ b/resources/views/emails/press-release/published.blade.php @@ -5,7 +5,11 @@

{{ $pressRelease->title }}

- Pressemitteilung ansehen + {{-- 1) Direktlink(s) zur veröffentlichten Mitteilung auf dem jeweiligen + öffentlichen Portal (bei Portal::Both je ein Button pro Spoke). --}} + @foreach ($portalLinks as $portalLink) + Auf {{ $portalLink['label'] }} ansehen + @endforeach
@if ($pressRelease->published_at) @@ -20,7 +24,20 @@ @endif
+ {{-- 2) Verwaltung im Konto (Login-Bereich), inkl. Hinweis auf die + Anmeldung per Passwort oder Magic-Link. --}} +

+ Bearbeiten und verwalten können Sie die Pressemitteilung in Ihrem Konto:
+ Pressemitteilung im pressekonto öffnen +

+

+ Hinweis: Dieser Link führt in Ihren persönlichen Login-Bereich. Die Anmeldung ist + dort mit Ihrem Passwort oder per Magic-Link (Anmelde-Link per E-Mail) möglich. +

+

Bei Fragen erreichen Sie uns über den Hilfe-Bereich auf pressekonto.

+ + diff --git a/resources/views/emails/press-release/rejected.blade.php b/resources/views/emails/press-release/rejected.blade.php index c0783cd1..525d2a61 100644 --- a/resources/views/emails/press-release/rejected.blade.php +++ b/resources/views/emails/press-release/rejected.blade.php @@ -19,4 +19,6 @@

Bei Fragen erreichen Sie uns über den Hilfe-Bereich auf pressekonto.

+ + diff --git a/resources/views/livewire/customer/profile.blade.php b/resources/views/livewire/customer/profile.blade.php index 0979f34c..5e7da065 100644 --- a/resources/views/livewire/customer/profile.blade.php +++ b/resources/views/livewire/customer/profile.blade.php @@ -1,5 +1,6 @@ aktiviert? + * Spiegel der Opt-out-Tabelle (email_notification_opt_outs) — vor jedem + * Versand prüft das System, ob der Typ abbestellt wurde. + * + * @var array + */ + public array $emailNotifications = []; + public function mount(): void { $user = auth()->user(); @@ -103,6 +113,12 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp if (filled($this->taxIdNumber)) { $this->refreshVatCheck(); } + + $optedOutTypes = $user->emailNotificationOptOuts()->pluck('type')->all(); + + foreach (EmailNotificationType::cases() as $notificationType) { + $this->emailNotifications[$notificationType->value] = ! in_array($notificationType->value, $optedOutTypes, true); + } } /** @@ -254,6 +270,14 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp ); } + foreach (EmailNotificationType::cases() as $notificationType) { + if ($this->emailNotifications[$notificationType->value] ?? true) { + $user->optIntoEmailNotification($notificationType); + } else { + $user->optOutOfEmailNotification($notificationType); + } + } + // Fokus-Fix: Nach dem Speichern soll kein Eingabefeld den Fokus // erhalten (Browser/Autofill springen sonst auf das erste Feld). $this->dispatch('profile-saved'); @@ -522,7 +546,34 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp - {{-- ============== 4) EINSTELLUNGEN ============== --}} + {{-- ============== 4) E-MAIL-BENACHRICHTIGUNGEN ============== --}} +
+
+ {{ __('E-Mail-Benachrichtigungen') }} +
+
+

+ {{ __('Welche Benachrichtigungs-E-Mails möchten Sie erhalten? System-E-Mails wie Passwort-Zurücksetzen oder rechtlich erforderliche Bestätigungen sind hiervon ausgenommen und werden immer zugestellt.') }} +

+
+ @foreach (\App\Enums\EmailNotificationType::cases() as $notificationType) + + @endforeach +
+
+
+ + {{ __('Speichern') }} + +
+
+ + {{-- ============== 5) EINSTELLUNGEN ============== --}}
{{ __('Einstellungen') }} diff --git a/resources/views/web/email-unsubscribe.blade.php b/resources/views/web/email-unsubscribe.blade.php new file mode 100644 index 00000000..08e8b7f4 --- /dev/null +++ b/resources/views/web/email-unsubscribe.blade.php @@ -0,0 +1,71 @@ +@extends('web.layouts.web-master') + +@section('title', 'E-Mail-Benachrichtigungen abbestellen') +@section('meta_description', 'Abmeldung von Benachrichtigungs-E-Mails') +@section('robots', 'noindex, nofollow') + +{{-- + One-Click-Unsubscribe-Bestätigungsseite: öffentlich ohne Login erreichbar + über den signierten Abmelde-Link im Footer der Benachrichtigungs-Mails. + Der Aufruf hat die Abmeldung bereits durchgeführt (EmailUnsubscribeController); + die Seite bestätigt sie und bietet das Rückgängigmachen an. +--}} + +@section('content') + +
+ @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. +

+
+ @csrf + +
+
+ @elseif ($state === 'resubscribed') +
+
Anmeldung bestätigt
+

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

+

+ Ihre Abmeldung wurde rückgängig gemacht. +

+
+ @else + + @endif + +

+ Eingeloggt können Sie alle E-Mail-Benachrichtigungen unter + Mein Profil + verwalten. +

+
+
+@endsection diff --git a/routes/customer.php b/routes/customer.php index de2d95b2..c32398ad 100644 --- a/routes/customer.php +++ b/routes/customer.php @@ -1,6 +1,7 @@ name('security'); }); +// ============================================================ +// E-Mail-Benachrichtigungen: One-Click-Abmeldung ohne Login. +// Signierte Links aus den Benachrichtigungs-Mails (kein Ablauf, damit +// auch alte Mails funktionieren). Der POST auf die Abmelde-URL bedient +// den List-Unsubscribe-Header (RFC 8058, ohne CSRF — Ausnahme in +// bootstrap/app.php); der Wieder-Anmelden-POST kommt von der +// Bestätigungsseite selbst. +// ============================================================ +Route::prefix('e-mail-benachrichtigungen') + ->name('email-preferences.') + ->middleware('throttle:30,1') + ->group(function () { + Route::get('abmelden/{user}/{type}', [EmailUnsubscribeController::class, 'show']) + ->name('unsubscribe'); + Route::post('abmelden/{user}/{type}', [EmailUnsubscribeController::class, 'oneClickUnsubscribe']) + ->name('unsubscribe.one-click'); + Route::post('anmelden/{user}/{type}', [EmailUnsubscribeController::class, 'resubscribe']) + ->name('resubscribe'); + }); + // ============================================================ // Legacy /customer/*-Pfade als 301-Redirect erhalten, // damit Bookmarks und alte Mails weiter funktionieren. diff --git a/tests/Feature/EmailNotificationPreferencesTest.php b/tests/Feature/EmailNotificationPreferencesTest.php new file mode 100644 index 00000000..730d031c --- /dev/null +++ b/tests/Feature/EmailNotificationPreferencesTest.php @@ -0,0 +1,199 @@ + $user->id, + 'type' => $type->value, + ]); +} + +test('notification mails contain the reason hint and a signed unsubscribe link', function () { + $pressRelease = PressRelease::factory()->published()->create(); + $user = $pressRelease->user; + + $html = (new PressReleasePublished($user, $pressRelease))->render(); + + expect($html) + ->toContain('Warum erhalten Sie diese E-Mail?') + ->toContain('Sie wurden als Pressekontakt hinterlegt') + ->toContain('hier mit einem Klick austragen') + ->toContain('e-mail-benachrichtigungen/abmelden/'.$user->id.'/'.EmailNotificationType::PressReleasePublished->value) + ->toContain('signature='); + + $rejectedHtml = (new PressReleaseRejected($user, $pressRelease, 'Bitte Quellen ergänzen.'))->render(); + + expect($rejectedHtml) + ->toContain('Warum erhalten Sie diese E-Mail?') + ->toContain('e-mail-benachrichtigungen/abmelden/'.$user->id.'/'.EmailNotificationType::PressReleaseRejected->value); +}); + +test('notification mails carry rfc 8058 list-unsubscribe headers', function () { + $pressRelease = PressRelease::factory()->published()->create(); + + $mailable = new PressReleasePublished($pressRelease->user, $pressRelease); + + $headers = $mailable->headers(); + + expect($headers->text['List-Unsubscribe']) + ->toContain('e-mail-benachrichtigungen/abmelden/') + ->and($headers->text['List-Unsubscribe-Post']) + ->toBe('List-Unsubscribe=One-Click'); +}); + +test('the signed unsubscribe link opts the user out without login and shows a confirmation', function () { + /** @var TestCase $this */ + $user = User::factory()->create(); + + $this->get(signedUnsubscribeUrl($user, EmailNotificationType::PressReleasePublished)) + ->assertSuccessful() + ->assertSee('Erfolgreich abgemeldet') + ->assertSee('Pressemitteilung veröffentlicht') + ->assertSee('Abmeldung rückgängig machen'); + + expect($user->fresh()->wantsEmailNotification(EmailNotificationType::PressReleasePublished))->toBeFalse() + ->and($user->fresh()->wantsEmailNotification(EmailNotificationType::PressReleaseRejected))->toBeTrue(); +}); + +test('an unsubscribe link with a tampered signature does not opt out', function () { + /** @var TestCase $this */ + $user = User::factory()->create(); + + $url = route('email-preferences.unsubscribe', [ + 'user' => $user->id, + 'type' => EmailNotificationType::PressReleasePublished->value, + ]).'?signature=manipuliert'; + + $this->get($url) + ->assertSuccessful() + ->assertSee('Dieser Abmelde-Link ist ungültig.'); + + expect($user->fresh()->wantsEmailNotification(EmailNotificationType::PressReleasePublished))->toBeTrue(); +}); + +test('mail clients can one-click unsubscribe via post without a csrf token', function () { + /** @var TestCase $this */ + $user = User::factory()->create(); + + $this->post(signedUnsubscribeUrl($user, EmailNotificationType::PressReleaseRejected)) + ->assertSuccessful(); + + expect($user->fresh()->wantsEmailNotification(EmailNotificationType::PressReleaseRejected))->toBeFalse(); +}); + +test('the confirmation page allows undoing the unsubscribe', function () { + /** @var TestCase $this */ + $user = User::factory()->create(); + $user->optOutOfEmailNotification(EmailNotificationType::PressReleasePublished); + + $resubscribeUrl = URL::signedRoute('email-preferences.resubscribe', [ + 'user' => $user->id, + 'type' => EmailNotificationType::PressReleasePublished->value, + ]); + + $this->post($resubscribeUrl) + ->assertSuccessful() + ->assertSee('Wieder angemeldet'); + + expect($user->fresh()->wantsEmailNotification(EmailNotificationType::PressReleasePublished))->toBeTrue(); +}); + +test('an unknown notification type returns 404', function () { + /** @var TestCase $this */ + $user = User::factory()->create(); + + $this->get(URL::signedRoute('email-preferences.unsubscribe', [ + 'user' => $user->id, + 'type' => 'gibt_es_nicht', + ]))->assertNotFound(); +}); + +test('the published notification is not sent to an opted-out author', function () { + /** @var TestCase $this */ + Mail::fake(); + + $pressRelease = PressRelease::factory()->create(['status' => PressReleaseStatus::Review->value]); + $pressRelease->user->optOutOfEmailNotification(EmailNotificationType::PressReleasePublished); + + app(PressReleaseService::class)->publish($pressRelease); + + expect($pressRelease->fresh()->status)->toBe(PressReleaseStatus::Published); + Mail::assertNothingOutgoing(); + Mail::assertNotQueued(PressReleasePublished::class); +}); + +test('the published notification is still sent to an author without opt-out', function () { + /** @var TestCase $this */ + Mail::fake(); + + $pressRelease = PressRelease::factory()->create(['status' => PressReleaseStatus::Review->value]); + + app(PressReleaseService::class)->publish($pressRelease); + + Mail::assertQueued(PressReleasePublished::class, fn (PressReleasePublished $mail) => $mail->hasTo($pressRelease->user->email)); +}); + +test('the rejected notification is not sent to an opted-out author', function () { + /** @var TestCase $this */ + Mail::fake(); + + $pressRelease = PressRelease::factory()->create(['status' => PressReleaseStatus::Review->value]); + $pressRelease->user->optOutOfEmailNotification(EmailNotificationType::PressReleaseRejected); + + app(PressReleaseService::class)->reject($pressRelease, 'Bitte überarbeiten.'); + + expect($pressRelease->fresh()->status)->toBe(PressReleaseStatus::Rejected); + Mail::assertNotQueued(PressReleaseRejected::class); +}); + +test('customers can manage their email notification preferences in the profile', function () { + /** @var TestCase $this */ + $this->seed(RolesAndPermissionsSeeder::class); + + $customer = User::factory()->create(['is_active' => true]); + $customer->assignRole('customer'); + + $this->actingAs($customer); + + LivewireVolt::test('customer.profile') + ->set('emailNotifications.'.EmailNotificationType::PressReleasePublished->value, false) + ->call('saveProfile') + ->assertHasNoErrors(); + + expect($customer->fresh()->wantsEmailNotification(EmailNotificationType::PressReleasePublished))->toBeFalse(); + + LivewireVolt::test('customer.profile') + ->set('emailNotifications.'.EmailNotificationType::PressReleasePublished->value, true) + ->call('saveProfile') + ->assertHasNoErrors(); + + expect($customer->fresh()->wantsEmailNotification(EmailNotificationType::PressReleasePublished))->toBeTrue(); +}); + +test('opting out twice keeps a single opt-out row', function () { + $user = User::factory()->create(); + + $user->optOutOfEmailNotification(EmailNotificationType::PressReleasePublished); + $user->optOutOfEmailNotification(EmailNotificationType::PressReleasePublished); + + expect(EmailNotificationOptOut::query()->where('user_id', $user->id)->count())->toBe(1); +}); diff --git a/tests/Feature/Mail/EmailBrandingTest.php b/tests/Feature/Mail/EmailBrandingTest.php index 4902addf..a18b2c83 100644 --- a/tests/Feature/Mail/EmailBrandingTest.php +++ b/tests/Feature/Mail/EmailBrandingTest.php @@ -85,7 +85,7 @@ test('the published mail names the portal with a link to the public detail page' expect($html) ->toContain('Veröffentlicht auf') - ->toContain('Presseecho') + ->toContain('Auf Presseecho ansehen') ->toContain(config('domains.domain_presseecho_url').'/de/press-release/'.$pressRelease->slug); }); @@ -95,12 +95,30 @@ test('the published mail links both portals for a cross-portal press release', f $html = (new PressReleasePublished($pressRelease->user, $pressRelease))->render(); expect($html) - ->toContain('Presseecho') - ->toContain('Businessportal24') + ->toContain('Auf Presseecho ansehen') + ->toContain('Auf Businessportal24 ansehen') ->toContain(config('domains.domain_presseecho_url').'/de/press-release/'.$pressRelease->slug) ->toContain(config('domains.domain_businessportal_url').'/de/press-release/'.$pressRelease->slug); }); +test('the published mail links the account area below the portal links with a login hint', function () { + $pressRelease = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->create(); + + $mailable = new PressReleasePublished($pressRelease->user, $pressRelease); + $html = $mailable->render(); + + $publicUrl = config('domains.domain_presseecho_url').'/de/press-release/'.$pressRelease->slug; + + expect($html) + ->toContain($mailable->showUrl) + ->toContain('Pressemitteilung im pressekonto öffnen') + ->toContain('Login-Bereich') + ->toContain('mit Ihrem Passwort oder per Magic-Link'); + + // Reihenfolge: erst der öffentliche Portal-Link, darunter der Konto-Link. + expect(mb_strpos($html, $publicUrl))->toBeLessThan(mb_strpos($html, 'Pressemitteilung im pressekonto öffnen')); +}); + test('the termination mails use the hub ci layout', function () { $terminationRequest = TerminationRequest::factory()->create();