E-Mail-Praeferenzen: Opt-out je Mail-Typ + One-Click-Unsubscribe ohne Login

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin Adametz 2026-07-10 17:15:05 +00:00
parent 06d80b255b
commit 51da374173
21 changed files with 746 additions and 9 deletions

View file

@ -0,0 +1,65 @@
<?php
namespace App\Enums;
/**
* Abbestellbare Benachrichtigungs-Mails (Opt-out je Typ).
*
* Umfasst bewusst nur Benachrichtigungen zum PM-Lebenszyklus — keine
* System-/Transaktionsmails (Passwort-Reset, Magic-Link, Kündigungs-/
* Widerrufsbestätigungen), die rechtlich bzw. funktional zwingend sind.
* Jeder Case entspricht genau einer Mailable-Klasse; vor dem Versand wird
* geprüft, ob der Empfänger den Typ abbestellt hat
* (User::wantsEmailNotification()).
*/
enum EmailNotificationType: string
{
case PressReleasePublished = 'press_release_published';
case PressReleaseRejected = 'press_release_rejected';
case PressReleaseDepublished = 'press_release_depublished';
public function label(): string
{
return match ($this) {
self::PressReleasePublished => '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',
};
}
}

View file

@ -0,0 +1,83 @@
<?php
namespace App\Http\Controllers;
use App\Enums\EmailNotificationType;
use App\Models\User;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\URL;
use Illuminate\View\View;
/**
* One-Click-Abmeldung von Benachrichtigungs-Mails, ohne Login.
*
* Die Links in den Mail-Footern sind signiert und ohne Ablauf (Links aus
* alten Mails bleiben gültig). Der GET-Aufruf trägt den Empfänger direkt
* aus und zeigt die Bestätigungsseite mit der Möglichkeit, die Abmeldung
* rückgängig zu machen. Der POST auf dieselbe URL bedient den
* List-Unsubscribe-Header (RFC 8058) — Mail-Clients senden ihn ohne
* Nutzer-Interaktion auf der Seite.
*/
class EmailUnsubscribeController extends Controller
{
public function show(Request $request, User $user, string $type): View
{
$notificationType = $this->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;
}
}

View file

@ -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));
}

View file

@ -0,0 +1,42 @@
<?php
namespace App\Mail\Concerns;
use App\Enums\EmailNotificationType;
use App\Models\User;
use Illuminate\Mail\Mailables\Headers;
use Illuminate\Support\Facades\URL;
/**
* Macht eine Benachrichtigungs-Mail abbestellbar: signierter Abmelde-Link
* für den Mail-Footer (ohne Login nutzbar, ohne Ablauf — der Link aus alten
* Mails bleibt gültig) plus die List-Unsubscribe-Header nach RFC 8058, mit
* denen Mail-Clients (Gmail, Apple Mail …) einen nativen
* „Abbestellen"-Button anbieten.
*
* Der Link wird im Konstruktor der Mailable erzeugt (configureUnsubscribe),
* damit er beim Queuen mit serialisiert wird.
*/
trait Unsubscribable
{
public string $unsubscribeUrl;
public EmailNotificationType $notificationType;
protected function configureUnsubscribe(User $user, EmailNotificationType $type): void
{
$this->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',
]);
}
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,40 @@
<?php
namespace App\Models;
use App\Enums\EmailNotificationType;
use Database\Factories\EmailNotificationOptOutFactory;
use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Database\Eloquent\Relations\BelongsTo;
/**
* Abbestellter Benachrichtigungs-Mail-Typ eines Users (Opt-out-Register).
* Existiert eine Zeile, wird die entsprechende Mail nicht mehr versendet —
* geprüft über User::wantsEmailNotification() vor jedem Versand.
*/
class EmailNotificationOptOut extends Model
{
/** @use HasFactory<EmailNotificationOptOutFactory> */
use HasFactory;
protected $fillable = [
'user_id',
'type',
];
/**
* @return array<string, string>
*/
protected function casts(): array
{
return [
'type' => EmailNotificationType::class,
];
}
public function user(): BelongsTo
{
return $this->belongsTo(User::class);
}
}

View file

@ -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);

View file

@ -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,

View file

@ -0,0 +1,25 @@
<?php
namespace Database\Factories;
use App\Enums\EmailNotificationType;
use App\Models\EmailNotificationOptOut;
use App\Models\User;
use Illuminate\Database\Eloquent\Factories\Factory;
/**
* @extends Factory<EmailNotificationOptOut>
*/
class EmailNotificationOptOutFactory extends Factory
{
/**
* @return array<string, mixed>
*/
public function definition(): array
{
return [
'user_id' => User::factory(),
'type' => fake()->randomElement(EmailNotificationType::cases()),
];
}
}

View file

@ -0,0 +1,31 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* Run the migrations.
*/
public function up(): void
{
Schema::create('email_notification_opt_outs', function (Blueprint $table) {
$table->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');
}
};

View file

@ -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.).
--}}
<div class="note">
<p>
<strong>Warum erhalten Sie diese E-Mail?</strong>
{{ $type->mailReason() }}
Wenn Sie keine {{ $type->unsubscribeLabel() }} mehr erhalten möchten,
können Sie sich <a href="{{ $url }}">hier mit einem Klick austragen</a>.
Ihre E-Mail-Benachrichtigungen verwalten Sie außerdem jederzeit in Ihrem
<a href="{{ config('domains.domain_portal_url') }}/admin/me/profile">Konto unter „Mein Profil"</a>.
</p>
</div>

View file

@ -18,4 +18,6 @@
<p>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.</p>
<p>Bei Fragen erreichen Sie uns über den <a href="{{ config('domains.domain_portal_url') }}/de/hilfe">Hilfe-Bereich auf pressekonto</a>.</p>
</div>
<x-emails.unsubscribe-note :type="$notificationType" :url="$unsubscribeUrl" />
</x-emails.layout>

View file

@ -5,7 +5,11 @@
<p class="title">{{ $pressRelease->title }}</p>
<x-emails.button :url="$showUrl">Pressemitteilung ansehen</x-emails.button>
{{-- 1) Direktlink(s) zur veröffentlichten Mitteilung auf dem jeweiligen
öffentlichen Portal (bei Portal::Both je ein Button pro Spoke). --}}
@foreach ($portalLinks as $portalLink)
<x-emails.button :url="$portalLink['url']">Auf {{ $portalLink['label'] }} ansehen</x-emails.button>
@endforeach
<dl class="meta">
@if ($pressRelease->published_at)
@ -20,7 +24,20 @@
@endif
</dl>
{{-- 2) Verwaltung im Konto (Login-Bereich), inkl. Hinweis auf die
Anmeldung per Passwort oder Magic-Link. --}}
<p>
Bearbeiten und verwalten können Sie die Pressemitteilung in Ihrem Konto:<br>
<a href="{{ $showUrl }}">Pressemitteilung im pressekonto öffnen</a>
</p>
<p style="font-size: 12.5px; color: #6b7280;">
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.
</p>
<div class="note">
<p>Bei Fragen erreichen Sie uns über den <a href="{{ config('domains.domain_portal_url') }}/de/hilfe">Hilfe-Bereich auf pressekonto</a>.</p>
</div>
<x-emails.unsubscribe-note :type="$notificationType" :url="$unsubscribeUrl" />
</x-emails.layout>

View file

@ -19,4 +19,6 @@
<div class="note">
<p>Bei Fragen erreichen Sie uns über den <a href="{{ config('domains.domain_portal_url') }}/de/hilfe">Hilfe-Bereich auf pressekonto</a>.</p>
</div>
<x-emails.unsubscribe-note :type="$notificationType" :url="$unsubscribeUrl" />
</x-emails.layout>

View file

@ -1,5 +1,6 @@
<?php
use App\Enums\EmailNotificationType;
use App\Enums\VatIdCheckStatus;
use App\Models\User;
use App\Services\Billing\VatIdValidationService;
@ -60,6 +61,15 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
public ?string $vatCheckMessage = null;
/**
* E-Mail-Benachrichtigungs-Präferenzen: Enum-Value => aktiviert?
* Spiegel der Opt-out-Tabelle (email_notification_opt_outs) — vor jedem
* Versand prüft das System, ob der Typ abbestellt wurde.
*
* @var array<string, bool>
*/
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
</div>
</article>
{{-- ============== 4) EINSTELLUNGEN ============== --}}
{{-- ============== 4) E-MAIL-BENACHRICHTIGUNGEN ============== --}}
<article class="panel" id="e-mail-benachrichtigungen">
<div class="panel-head">
<span class="section-eyebrow">{{ __('E-Mail-Benachrichtigungen') }}</span>
</div>
<div class="p-5 space-y-4">
<p class="text-[12.5px] leading-[1.55] text-[color:var(--color-ink-3)] m-0 max-w-[680px]">
{{ __('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.') }}
</p>
<div class="grid gap-4 md:grid-cols-2">
@foreach (\App\Enums\EmailNotificationType::cases() as $notificationType)
<flux:switch
wire:model="emailNotifications.{{ $notificationType->value }}"
align="right"
:label="$notificationType->label()"
:description="$notificationType->settingsDescription()"
/>
@endforeach
</div>
</div>
<div class="px-5 py-4 border-t border-[color:var(--color-bg-rule)] flex justify-end">
<flux:button type="submit" variant="primary">
{{ __('Speichern') }}
</flux:button>
</div>
</article>
{{-- ============== 5) EINSTELLUNGEN ============== --}}
<article class="panel">
<div class="panel-head">
<span class="section-eyebrow">{{ __('Einstellungen') }}</span>

View file

@ -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')
<x-web.static-page
eyebrow="E-Mail-Benachrichtigungen"
title="{{ $state === 'invalid' ? 'Link ungültig' : ($state === 'resubscribed' ? 'Wieder angemeldet' : 'Erfolgreich abgemeldet') }}"
breadcrumb="Abmeldung"
>
<div class="max-w-[680px]">
@if ($state === 'unsubscribed')
<div class="border border-bg-rule bg-bg-elev p-6" role="status">
<div class="section-eyebrow mb-3">Abmeldung bestätigt</div>
<h2 class="text-[19px] font-bold text-ink tracking-[-0.3px] m-0 mb-2">
Sie erhalten keine E-Mails vom Typ „{{ $notificationType->label() }}" mehr.
</h2>
<p class="text-[13.5px] text-ink-2 leading-[1.6] m-0 mb-4">
Die Abmeldung gilt nur für {{ $notificationType->unsubscribeLabel() }}.
System-E-Mails wie Passwort-Zurücksetzen oder rechtlich erforderliche
Bestätigungen erhalten Sie weiterhin.
</p>
<form method="POST" action="{{ $resubscribeUrl }}">
@csrf
<button type="submit"
class="inline-flex items-center px-4 py-2.5 text-[13px] font-semibold border border-bg-rule bg-white text-ink hover:border-hub transition-colors cursor-pointer">
Abmeldung rückgängig machen
</button>
</form>
</div>
@elseif ($state === 'resubscribed')
<div class="border border-bg-rule bg-bg-elev p-6" role="status">
<div class="section-eyebrow mb-3">Anmeldung bestätigt</div>
<h2 class="text-[19px] font-bold text-ink tracking-[-0.3px] m-0 mb-2">
Sie erhalten wieder E-Mails vom Typ „{{ $notificationType->label() }}".
</h2>
<p class="text-[13.5px] text-ink-2 leading-[1.6] m-0">
Ihre Abmeldung wurde rückgängig gemacht.
</p>
</div>
@else
<div class="border border-bg-rule bg-bg-elev p-6" role="alert">
<div class="section-eyebrow mb-3">Link ungültig</div>
<h2 class="text-[19px] font-bold text-ink tracking-[-0.3px] m-0 mb-2">
Dieser Abmelde-Link ist ungültig.
</h2>
<p class="text-[13.5px] text-ink-2 leading-[1.6] m-0">
Bitte verwenden Sie den Link aus einer aktuellen E-Mail. Alternativ können Sie
Ihre E-Mail-Benachrichtigungen jederzeit in Ihrem Konto unter
„Mein Profil" verwalten.
</p>
</div>
@endif
<p class="text-[12.5px] text-ink-3 leading-[1.6] mt-6">
Eingeloggt können Sie alle E-Mail-Benachrichtigungen unter
<a href="{{ route('me.profile') }}" class="text-hub underline underline-offset-2">Mein Profil</a>
verwalten.
</p>
</div>
</x-web.static-page>
@endsection

View file

@ -1,6 +1,7 @@
<?php
use App\Http\Controllers\CheckoutController;
use App\Http\Controllers\EmailUnsubscribeController;
use App\Http\Controllers\LegacyInvoicePdfController;
use App\Http\Controllers\ProofPdfController;
use App\Http\Controllers\StripeInvoiceRedirectController;
@ -62,6 +63,26 @@ Route::middleware(['auth', 'verified', EnsureUserIsCustomer::class, LogSlowAdmin
Volt::route('security', 'customer.security')->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.

View file

@ -0,0 +1,199 @@
<?php
use App\Enums\EmailNotificationType;
use App\Enums\PressReleaseStatus;
use App\Mail\PressReleasePublished;
use App\Mail\PressReleaseRejected;
use App\Models\EmailNotificationOptOut;
use App\Models\PressRelease;
use App\Models\User;
use App\Services\PressRelease\PressReleaseService;
use Database\Seeders\RolesAndPermissionsSeeder;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\URL;
use Livewire\Volt\Volt as LivewireVolt;
use Tests\TestCase;
/**
* Abbestellbare Benachrichtigungs-Mails: Hinweis + One-Click-Unsubscribe im
* Mail-Footer, öffentliche Abmelde-Seite ohne Login (signierte URL) und das
* Opt-out-Gate vor dem Versand.
*/
function signedUnsubscribeUrl(User $user, EmailNotificationType $type): string
{
return URL::signedRoute('email-preferences.unsubscribe', [
'user' => $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);
});

View file

@ -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();