presseportale/app/Http/Controllers/EmailUnsubscribeController.php
Kevin Adametz 35db30e019 mail: adressbezogene Abmeldung fuer Empfaenger ohne Konto (Roadmap 1d.3)
Der Abmeldelink in API-Benachrichtigungen zeigte auf das Konto des
Einlieferers - ein Klick des Pressekontakts haette connektar abgemeldet.
Jetzt traegt der Link die Empfaengeradresse (signiert), der Zustand liegt
als Unsubscribed in der Sperrliste. Anmeldelinks, die die Person selbst
anfordert, gehen weiter durch; harte Bounces sperren alles.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-16 14:35:47 +00:00

187 lines
7.1 KiB
PHP

<?php
namespace App\Http\Controllers;
use App\Enums\ActivityType;
use App\Enums\EmailNotificationType;
use App\Models\User;
use App\Services\ActivityLog;
use App\Services\Company\NewsroomClaimService;
use App\Services\Mail\EmailSuppressionService;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
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.
*
* Jede tatsächliche Zustandsänderung landet im Aktivitäts-Verlauf
* (Admin ▸ Reports ▸ Verlauf) — wiederholte Klicks erzeugen keine
* Doppel-Einträge.
*/
class EmailUnsubscribeController extends Controller
{
public function __construct(private readonly ActivityLog $activityLog) {}
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,
]);
}
$this->optOut($user, $notificationType, 'link');
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);
$this->optOut($user, $this->resolveType($type), 'one-click');
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);
if (! $user->wantsEmailNotification($notificationType)) {
$user->optIntoEmailNotification($notificationType);
$this->activityLog->record(
ActivityType::EmailUnsubscribe,
sprintf('%s (%s) hat %s wieder aktiviert.', $user->name, $user->email, $notificationType->unsubscribeLabel()),
user: $user,
meta: ['type' => $notificationType->value, 'action' => 'resubscribe'],
);
}
return view('web.email-unsubscribe', [
'state' => 'resubscribed',
'notificationType' => $notificationType,
]);
}
/**
* Adressbezogene Abmeldung (Roadmap 1d.3): Empfänger ohne Konto. Die
* Sperrliste hält den Zustand; Anmeldelinks, die die Person selbst
* anfordert, gehen weiter durch (GateOutgoingMail).
*/
public function showAddress(Request $request, EmailSuppressionService $suppressions): View
{
$notificationType = $this->resolveType((string) $request->query('type', ''));
$email = EmailSuppressionService::normalise((string) $request->query('email', ''));
if (! $request->hasValidSignature() || $email === null) {
return view('web.email-unsubscribe', ['state' => 'invalid', 'notificationType' => $notificationType]);
}
$this->unsubscribeAddress($suppressions, $email, $notificationType, 'link');
return view('web.email-unsubscribe', [
'state' => 'unsubscribed',
'notificationType' => $notificationType,
'address' => NewsroomClaimService::mask($email),
'addressMode' => true,
'resubscribeUrl' => URL::signedRoute('email-preferences.resubscribe-address', ['email' => $email, 'type' => $notificationType->value]),
]);
}
public function oneClickUnsubscribeAddress(Request $request, EmailSuppressionService $suppressions): Response
{
abort_unless($request->hasValidSignature(), 403);
$email = EmailSuppressionService::normalise((string) $request->query('email', ''));
abort_if($email === null, 404);
$this->unsubscribeAddress($suppressions, $email, $this->resolveType((string) $request->query('type', '')), 'one-click');
return response('Sie wurden erfolgreich abgemeldet.');
}
public function resubscribeAddress(Request $request, EmailSuppressionService $suppressions): View
{
abort_unless($request->hasValidSignature(), 403);
$email = EmailSuppressionService::normalise((string) $request->query('email', ''));
abort_if($email === null, 404);
$notificationType = $this->resolveType((string) $request->query('type', ''));
if ($suppressions->resubscribe($email)) {
$this->activityLog->record(
ActivityType::EmailUnsubscribe,
sprintf('%s hat die Abmeldung zurückgenommen.', $email),
meta: ['email' => $email, 'action' => 'resubscribe'],
);
}
return view('web.email-unsubscribe', ['state' => 'resubscribed', 'notificationType' => $notificationType, 'addressMode' => true]);
}
private function unsubscribeAddress(EmailSuppressionService $suppressions, string $email, EmailNotificationType $notificationType, string $method): void
{
if ($suppressions->isSuppressed($email)) {
return;
}
$suppressions->unsubscribe($email, $method);
$this->activityLog->record(
ActivityType::EmailUnsubscribe,
sprintf('%s hat sich von Benachrichtigungen abgemeldet (%s).', $email, $notificationType->unsubscribeLabel()),
meta: ['email' => $email, 'type' => $notificationType->value, 'action' => 'unsubscribe', 'method' => $method],
);
}
private function optOut(User $user, EmailNotificationType $notificationType, string $method): void
{
if (! $user->wantsEmailNotification($notificationType)) {
return;
}
$user->optOutOfEmailNotification($notificationType);
$this->activityLog->record(
ActivityType::EmailUnsubscribe,
sprintf('%s (%s) hat %s abbestellt.', $user->name, $user->email, $notificationType->unsubscribeLabel()),
user: $user,
meta: ['type' => $notificationType->value, 'action' => 'unsubscribe', 'method' => $method],
);
}
private function resolveType(string $type): EmailNotificationType
{
$notificationType = EmailNotificationType::tryFrom($type);
abort_if($notificationType === null, 404);
return $notificationType;
}
}