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:
parent
06d80b255b
commit
51da374173
21 changed files with 746 additions and 9 deletions
65
app/Enums/EmailNotificationType.php
Normal file
65
app/Enums/EmailNotificationType.php
Normal 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',
|
||||
};
|
||||
}
|
||||
}
|
||||
83
app/Http/Controllers/EmailUnsubscribeController.php
Normal file
83
app/Http/Controllers/EmailUnsubscribeController.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
||||
|
|
|
|||
42
app/Mail/Concerns/Unsubscribable.php
Normal file
42
app/Mail/Concerns/Unsubscribable.php
Normal 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',
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
40
app/Models/EmailNotificationOptOut.php
Normal file
40
app/Models/EmailNotificationOptOut.php
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
25
database/factories/EmailNotificationOptOutFactory.php
Normal file
25
database/factories/EmailNotificationOptOutFactory.php
Normal 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()),
|
||||
];
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
}
|
||||
};
|
||||
22
resources/views/components/emails/unsubscribe-note.blade.php
Normal file
22
resources/views/components/emails/unsubscribe-note.blade.php
Normal 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>
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
71
resources/views/web/email-unsubscribe.blade.php
Normal file
71
resources/views/web/email-unsubscribe.blade.php
Normal 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
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
199
tests/Feature/EmailNotificationPreferencesTest.php
Normal file
199
tests/Feature/EmailNotificationPreferencesTest.php
Normal 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);
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue