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>
This commit is contained in:
parent
13716b9c06
commit
9380937562
15 changed files with 362 additions and 20 deletions
|
|
@ -10,12 +10,20 @@ enum EmailSuppressionReason: string
|
|||
|
||||
case Manual = 'manual';
|
||||
|
||||
/**
|
||||
* Abmeldung durch den Empfänger selbst (Roadmap 1d.3) — kein
|
||||
* Zustellproblem. Bleibt für Benachrichtigungen wirksam, lässt aber
|
||||
* durch, was die Person selbst anfordert (Anmeldelink).
|
||||
*/
|
||||
case Unsubscribed = 'unsubscribed';
|
||||
|
||||
public function label(): string
|
||||
{
|
||||
return match ($this) {
|
||||
self::HardBounce => 'Postfach existiert nicht',
|
||||
self::SoftBounces => 'wiederholt unzustellbar',
|
||||
self::Manual => 'manuell gesperrt',
|
||||
self::Unsubscribed => 'abgemeldet',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ use App\Enums\ActivityType;
|
|||
use App\Enums\EmailNotificationType;
|
||||
use App\Models\User;
|
||||
use App\Services\ActivityLog;
|
||||
use App\Services\Company\NewsroomClaimService;
|
||||
use App\Services\Mail\EmailSuppressionService;
|
||||
use Illuminate\Http\RedirectResponse;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
|
|
@ -89,6 +91,75 @@ class EmailUnsubscribeController extends Controller
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Adressbezogene Abmeldung (Roadmap 1d.3): Empfänger ohne Konto. Die
|
||||
* Sperrliste hält den Zustand; Anmeldelinks, die die Person selbst
|
||||
* anfordert, gehen weiter durch (GateOutgoingMail).
|
||||
*/
|
||||
public function showAddress(Request $request, EmailSuppressionService $suppressions): View
|
||||
{
|
||||
$notificationType = $this->resolveType((string) $request->query('type', ''));
|
||||
$email = EmailSuppressionService::normalise((string) $request->query('email', ''));
|
||||
|
||||
if (! $request->hasValidSignature() || $email === null) {
|
||||
return view('web.email-unsubscribe', ['state' => 'invalid', 'notificationType' => $notificationType]);
|
||||
}
|
||||
|
||||
$this->unsubscribeAddress($suppressions, $email, $notificationType, 'link');
|
||||
|
||||
return view('web.email-unsubscribe', [
|
||||
'state' => 'unsubscribed',
|
||||
'notificationType' => $notificationType,
|
||||
'address' => NewsroomClaimService::mask($email),
|
||||
'addressMode' => true,
|
||||
'resubscribeUrl' => URL::signedRoute('email-preferences.resubscribe-address', ['email' => $email, 'type' => $notificationType->value]),
|
||||
]);
|
||||
}
|
||||
|
||||
public function oneClickUnsubscribeAddress(Request $request, EmailSuppressionService $suppressions): Response
|
||||
{
|
||||
abort_unless($request->hasValidSignature(), 403);
|
||||
$email = EmailSuppressionService::normalise((string) $request->query('email', ''));
|
||||
abort_if($email === null, 404);
|
||||
|
||||
$this->unsubscribeAddress($suppressions, $email, $this->resolveType((string) $request->query('type', '')), 'one-click');
|
||||
|
||||
return response('Sie wurden erfolgreich abgemeldet.');
|
||||
}
|
||||
|
||||
public function resubscribeAddress(Request $request, EmailSuppressionService $suppressions): View
|
||||
{
|
||||
abort_unless($request->hasValidSignature(), 403);
|
||||
$email = EmailSuppressionService::normalise((string) $request->query('email', ''));
|
||||
abort_if($email === null, 404);
|
||||
$notificationType = $this->resolveType((string) $request->query('type', ''));
|
||||
|
||||
if ($suppressions->resubscribe($email)) {
|
||||
$this->activityLog->record(
|
||||
ActivityType::EmailUnsubscribe,
|
||||
sprintf('%s hat die Abmeldung zurückgenommen.', $email),
|
||||
meta: ['email' => $email, 'action' => 'resubscribe'],
|
||||
);
|
||||
}
|
||||
|
||||
return view('web.email-unsubscribe', ['state' => 'resubscribed', 'notificationType' => $notificationType, 'addressMode' => true]);
|
||||
}
|
||||
|
||||
private function unsubscribeAddress(EmailSuppressionService $suppressions, string $email, EmailNotificationType $notificationType, string $method): void
|
||||
{
|
||||
if ($suppressions->isSuppressed($email)) {
|
||||
return;
|
||||
}
|
||||
|
||||
$suppressions->unsubscribe($email, $method);
|
||||
|
||||
$this->activityLog->record(
|
||||
ActivityType::EmailUnsubscribe,
|
||||
sprintf('%s hat sich von Benachrichtigungen abgemeldet (%s).', $email, $notificationType->unsubscribeLabel()),
|
||||
meta: ['email' => $email, 'type' => $notificationType->value, 'action' => 'unsubscribe', 'method' => $method],
|
||||
);
|
||||
}
|
||||
|
||||
private function optOut(User $user, EmailNotificationType $notificationType, string $method): void
|
||||
{
|
||||
if (! $user->wantsEmailNotification($notificationType)) {
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ namespace App\Listeners;
|
|||
|
||||
use App\Enums\ActivityType;
|
||||
use App\Exceptions\MailDeliveryPausedException;
|
||||
use App\Mail\MagicLoginLink;
|
||||
use App\Mail\OperationalAlertMail;
|
||||
use App\Services\ActivityLog;
|
||||
use App\Services\Mail\EmailSuppressionService;
|
||||
|
|
@ -30,6 +31,9 @@ use Symfony\Component\Mime\Address;
|
|||
*/
|
||||
class GateOutgoingMail
|
||||
{
|
||||
/** Mails, die der Empfänger selbst auslöst — eine Abmeldung hält sie nicht auf. */
|
||||
private const REQUESTED_BY_RECIPIENT = [MagicLoginLink::class];
|
||||
|
||||
public function __construct(
|
||||
private readonly EmailSuppressionService $suppressions,
|
||||
private readonly MailCircuitBreaker $breaker,
|
||||
|
|
@ -66,7 +70,10 @@ class GateOutgoingMail
|
|||
$to = $this->addresses($message->getTo());
|
||||
$cc = $this->addresses($message->getCc());
|
||||
$bcc = $this->addresses($message->getBcc());
|
||||
$suppressed = $this->suppressions->suppressedAmong([...$to, ...$cc, ...$bcc]);
|
||||
// Eine Abmeldung (1d.3) gilt für Benachrichtigungen — nicht für den
|
||||
// Anmeldelink, den die Person gerade selbst angefordert hat.
|
||||
$requested = in_array($mailable, self::REQUESTED_BY_RECIPIENT, true);
|
||||
$suppressed = $this->suppressions->suppressedAmong([...$to, ...$cc, ...$bcc], includeUnsubscribed: ! $requested);
|
||||
|
||||
if ($suppressed === []) {
|
||||
return true;
|
||||
|
|
|
|||
|
|
@ -32,6 +32,20 @@ trait Unsubscribable
|
|||
]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Abmeldung für Empfänger ohne Konto (Roadmap 1d.3): Pressekontakt oder
|
||||
* Firmen-E-Mail einer API-Meldung. Der Link trägt die Adresse selbst —
|
||||
* ein Konto-Link würde den Einlieferer abmelden, nicht den Empfänger.
|
||||
*/
|
||||
protected function configureAddressUnsubscribe(string $email, EmailNotificationType $type): void
|
||||
{
|
||||
$this->notificationType = $type;
|
||||
$this->unsubscribeUrl = URL::signedRoute('email-preferences.unsubscribe-address', [
|
||||
'email' => mb_strtolower(trim($email)),
|
||||
'type' => $type->value,
|
||||
]);
|
||||
}
|
||||
|
||||
public function headers(): Headers
|
||||
{
|
||||
return new Headers(text: array_filter([
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ class PressReleasePublished extends Mailable
|
|||
public readonly User $user,
|
||||
public readonly PressRelease $pressRelease,
|
||||
?string $recipientName = null,
|
||||
?string $recipientEmail = null,
|
||||
) {
|
||||
$this->recipientName = $recipientName ?? $user->name;
|
||||
$this->showUrl = self::tagged(
|
||||
|
|
@ -62,7 +63,13 @@ class PressReleasePublished extends Mailable
|
|||
|
||||
$this->manageNewsroomUrl = $this->manageNewsroomUrl($pressRelease);
|
||||
|
||||
$this->configureUnsubscribe($user, EmailNotificationType::PressReleasePublished);
|
||||
// Geht die Mail an einen Empfänger ohne Konto (API-Meldung), meldet
|
||||
// der Abmeldelink diese Adresse ab — nicht das Konto des Einlieferers.
|
||||
if ($recipientEmail !== null && mb_strtolower(trim($recipientEmail)) !== mb_strtolower((string) $user->email)) {
|
||||
$this->configureAddressUnsubscribe($recipientEmail, EmailNotificationType::PressReleasePublished);
|
||||
} else {
|
||||
$this->configureUnsubscribe($user, EmailNotificationType::PressReleasePublished);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ class PressReleaseRejected extends Mailable
|
|||
public readonly PressRelease $pressRelease,
|
||||
public readonly ?string $reason = null,
|
||||
?string $recipientName = null,
|
||||
?string $recipientEmail = null,
|
||||
) {
|
||||
$this->recipientName = $recipientName ?? $user->name;
|
||||
$editUrl = $user->canAccessAdmin()
|
||||
|
|
@ -49,7 +50,13 @@ class PressReleaseRejected extends Mailable
|
|||
|
||||
$this->helpUrl = $this->helpUrl($pressRelease);
|
||||
|
||||
$this->configureUnsubscribe($user, EmailNotificationType::PressReleaseRejected);
|
||||
// Geht die Mail an einen Empfänger ohne Konto (API-Meldung), meldet
|
||||
// der Abmeldelink diese Adresse ab — nicht das Konto des Einlieferers.
|
||||
if ($recipientEmail !== null && mb_strtolower(trim($recipientEmail)) !== mb_strtolower((string) $user->email)) {
|
||||
$this->configureAddressUnsubscribe($recipientEmail, EmailNotificationType::PressReleaseRejected);
|
||||
} else {
|
||||
$this->configureUnsubscribe($user, EmailNotificationType::PressReleaseRejected);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -66,7 +66,9 @@ class NewsroomClaimService
|
|||
return collect();
|
||||
}
|
||||
|
||||
$suppressed = $this->suppressions->suppressedAmong($candidates);
|
||||
// Abgemeldete Adressen (1d.3) bleiben beanspruchbar — den Anmeldelink
|
||||
// fordert die Person selbst an.
|
||||
$suppressed = $this->suppressions->suppressedAmong($candidates, includeUnsubscribed: false);
|
||||
|
||||
return collect(array_values(array_filter($candidates, fn (string $email): bool => ! in_array($email, $suppressed, true))));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -35,7 +35,13 @@ class EmailSuppressionService
|
|||
return $email === '' ? null : $email;
|
||||
}
|
||||
|
||||
public function isSuppressed(?string $email): bool
|
||||
/**
|
||||
* @param bool $includeUnsubscribed false = nur Zustellprobleme zählen;
|
||||
* eine Abmeldung (1d.3) hält
|
||||
* Benachrichtigungen ab, aber nicht,
|
||||
* was die Person selbst anfordert
|
||||
*/
|
||||
public function isSuppressed(?string $email, bool $includeUnsubscribed = true): bool
|
||||
{
|
||||
$key = self::normalise($email);
|
||||
|
||||
|
|
@ -43,7 +49,11 @@ class EmailSuppressionService
|
|||
return false;
|
||||
}
|
||||
|
||||
return EmailSuppression::query()->active()->where('email', $key)->exists();
|
||||
return EmailSuppression::query()
|
||||
->active()
|
||||
->where('email', $key)
|
||||
->when(! $includeUnsubscribed, fn ($q) => $q->where('reason', '!=', EmailSuppressionReason::Unsubscribed->value))
|
||||
->exists();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -53,7 +63,7 @@ class EmailSuppressionService
|
|||
* @param list<string> $emails
|
||||
* @return list<string> normalisiert
|
||||
*/
|
||||
public function suppressedAmong(array $emails): array
|
||||
public function suppressedAmong(array $emails, bool $includeUnsubscribed = true): array
|
||||
{
|
||||
$keys = array_values(array_filter(array_map(self::normalise(...), $emails)));
|
||||
|
||||
|
|
@ -61,7 +71,53 @@ class EmailSuppressionService
|
|||
return [];
|
||||
}
|
||||
|
||||
return EmailSuppression::query()->active()->whereIn('email', $keys)->pluck('email')->all();
|
||||
return EmailSuppression::query()
|
||||
->active()
|
||||
->whereIn('email', $keys)
|
||||
->when(! $includeUnsubscribed, fn ($q) => $q->where('reason', '!=', EmailSuppressionReason::Unsubscribed->value))
|
||||
->pluck('email')
|
||||
->all();
|
||||
}
|
||||
|
||||
/**
|
||||
* Abmeldung durch den Empfänger (Roadmap 1d.3): für Adressen ohne Konto
|
||||
* — Pressekontakt, Firmen-E-Mail — ist die Sperrliste der einzige Ort,
|
||||
* an dem „will keine Benachrichtigungen" stehen kann. Eine bestehende
|
||||
* Zustellsperre bleibt, was sie ist.
|
||||
*/
|
||||
public function unsubscribe(string $email, string $method = 'link'): ?EmailSuppression
|
||||
{
|
||||
$key = self::normalise($email);
|
||||
|
||||
if ($key === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$existing = EmailSuppression::query()->active()->where('email', $key)->first();
|
||||
|
||||
if ($existing !== null && $existing->reason !== EmailSuppressionReason::Unsubscribed) {
|
||||
return $existing;
|
||||
}
|
||||
|
||||
return $this->suppress($key, EmailSuppressionReason::Unsubscribed, 'unsubscribe', 'Abmeldung durch Empfänger ('.$method.')');
|
||||
}
|
||||
|
||||
/**
|
||||
* Abmeldung zurücknehmen — nur eine Abmeldung; Zustellsperren löst nur
|
||||
* der Admin.
|
||||
*/
|
||||
public function resubscribe(string $email): bool
|
||||
{
|
||||
$key = self::normalise($email);
|
||||
$suppression = $key === null ? null : EmailSuppression::query()->active()->where('email', $key)->where('reason', EmailSuppressionReason::Unsubscribed->value)->first();
|
||||
|
||||
if ($suppression === null) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->release($suppression, null, 'Abmeldung durch Empfänger zurückgenommen');
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -794,8 +794,8 @@ class PressReleaseService
|
|||
}
|
||||
|
||||
$mailable = $notificationType === EmailNotificationType::PressReleasePublished
|
||||
? new PressReleasePublished($user, $pressRelease, $recipientName)
|
||||
: new PressReleaseRejected($user, $pressRelease, $reason, $recipientName);
|
||||
? new PressReleasePublished($user, $pressRelease, $recipientName, $recipient)
|
||||
: new PressReleaseRejected($user, $pressRelease, $reason, $recipientName, $recipient);
|
||||
|
||||
Mail::to($recipient)->queue($mailable);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -77,6 +77,7 @@ return Application::configure(basePath: dirname(__DIR__))
|
|||
// CSRF-Schutz.
|
||||
$middleware->validateCsrfTokens(except: [
|
||||
'e-mail-benachrichtigungen/abmelden/*',
|
||||
'e-mail-benachrichtigungen/abmelden-adresse',
|
||||
]);
|
||||
|
||||
$middleware->alias([
|
||||
|
|
|
|||
|
|
@ -146,7 +146,7 @@ Für selbst angelegte Firmendaten trägt der Nutzer die Verantwortung. Für die
|
|||
|---|---|---|---|
|
||||
| ~~1d.1~~ | Hilfeseite ohne kommerziellen Anteil — **gebaut 16.09.2026**: `/{edition}/gute-pressemitteilung` (DE/EN), Anlass, Aufbau, Sprache, Prüfablauf, sieben Qualitätsdimensionen offen, Pressebilder; Sitemap + Hilfe-Center | S | keine |
|
||||
| ~~1d.2~~ | Textbaustein in die Ablehnungsmail — **16.09.2026**: Link auf dem Portal der Meldung, Herkunft `mail_reject`, nichts Kommerzielles (E6) | S | 1d.1 |
|
||||
| 1d.3 | Abmeldemöglichkeit für Benachrichtigungen, Status in der Suppression-Logik geführt | S | Bounce-Suppression live |
|
||||
| ~~1d.3~~ | Abmeldemöglichkeit für Benachrichtigungen — **16.09.2026**: für Empfänger ohne Konto (Pressekontakt, Firmen-E-Mail) adressbezogener Abmeldelink, Zustand als `Unsubscribed` in der Sperrliste; selbst angeforderte Anmeldelinks gehen weiter durch. Vorher zeigte der Link in API-Benachrichtigungen auf das Konto des Einlieferers | S | Bounce-Suppression live |
|
||||
| ~~1d.4~~ | Prüfwerkzeug — **E3 entschieden 16.09.2026: kein eigenständiges Werkzeug.** Prüfung nur beim Freigeben, Ergebnis am Entwurf; „Zur Prüfung freigeben" mit Erklärung, was danach passiert; automatische Prüfung je Meldung `max_automatic_attempts` (3), danach geblockt in die Admin-Queue („Prüfung nötig"), Freischalten oder Kommentar, Automatik für die Meldung ab dann aus | M | 1d.1 |
|
||||
| ~~1d.5~~ | Bewertung einer bestehenden Meldung über Magic Link — **mit 1c.2 erledigt**: der Beansprucher sieht Ablehnungsgrund, KI-Stufe und Content-Score auf der Meldungsseite | S | 1d.4, 1b.2 |
|
||||
| ~~1d.6~~ | Partnerbereich am Seitenende als BALD — **16.09.2026** am Ende von 1d.1 | S | 1d.1 |
|
||||
|
|
|
|||
|
|
@ -1406,3 +1406,31 @@ Beansprucher sieht Ablehnungsgrund, KI-Stufe, Content-Score).
|
|||
|
||||
Deploy: `migrate --force` (Millisekunden), `view:cache`, `queue:restart`.
|
||||
Kein Backfill — bestehende Meldungen starten mit 0 Versuchen.
|
||||
|
||||
## 1d.3 — Abmeldung für Empfänger ohne Konto
|
||||
|
||||
**Befund:** Die Benachrichtigungen zu API-Meldungen gehen an Pressekontakt
|
||||
oder Firmen-E-Mail — der Abmeldelink darin war aber für das *Konto des
|
||||
Einlieferers* signiert (`configureUnsubscribe($user, …)`). Ein Klick des
|
||||
Pressekontakts hätte connektar abgemeldet, nicht sich selbst.
|
||||
|
||||
**Umsetzung:** `EmailSuppressionReason::Unsubscribed` als dritter Zustand
|
||||
in der Sperrliste. Mailables bekommen den tatsächlichen Empfänger
|
||||
(`$recipientEmail`); weicht er vom Konto ab, trägt der Link die Adresse
|
||||
(`/e-mail-benachrichtigungen/abmelden-adresse?email=…&type=…`, signiert,
|
||||
One-Click-POST ohne CSRF wie bisher). Die Abmeldung gilt für alle
|
||||
Benachrichtigungen an die Adresse; Rücknahme von der Bestätigungsseite.
|
||||
Eine bestehende Zustellsperre wird nicht zur Abmeldung herabgestuft, und
|
||||
eine Abmeldung löst nur der Empfänger selbst.
|
||||
|
||||
**Ausnahme im Gate:** `GateOutgoingMail` lässt `MagicLoginLink` an
|
||||
abgemeldete Adressen durch (`suppressedAmong(…, includeUnsubscribed:
|
||||
false)`), die Claim-Seite behandelt sie als erreichbar — den Anmeldelink
|
||||
fordert die Person selbst an. Harte Bounces sperren weiterhin alles.
|
||||
|
||||
Tests: `AddressUnsubscribeTest` (5). Admin ▸ Mail-Sperrliste zeigt den
|
||||
Grund „abgemeldet" ohne weitere Änderung.
|
||||
|
||||
**Damit ist Block 1d komplett.** Offen aus dem Block: ob der Autor beim
|
||||
Block nach dem N-ten Rot eine kurze Mail bekommen soll („ein Mensch
|
||||
prüft"); heute sieht er es nur im Konto.
|
||||
|
|
|
|||
|
|
@ -21,14 +21,27 @@
|
|||
@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>
|
||||
@if ($addressMode ?? false)
|
||||
{{-- Roadmap 1d.3: Empfänger ohne Konto — die Abmeldung gilt
|
||||
für die Adresse und alle Benachrichtigungen. --}}
|
||||
<h2 class="text-[19px] font-bold text-ink tracking-[-0.3px] m-0 mb-2">
|
||||
An {{ $address }} gehen keine Benachrichtigungen mehr.
|
||||
</h2>
|
||||
<p class="text-[13.5px] text-ink-2 leading-[1.6] m-0 mb-4">
|
||||
Die Abmeldung gilt für alle Benachrichtigungen zu Pressemitteilungen an diese Adresse.
|
||||
Einen Anmeldelink, den Sie selbst anfordern — etwa um den Newsroom Ihrer Firma zu
|
||||
verwalten —, erhalten Sie weiterhin.
|
||||
</p>
|
||||
@else
|
||||
<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>
|
||||
@endif
|
||||
<form method="POST" action="{{ $resubscribeUrl }}">
|
||||
@csrf
|
||||
<button type="submit"
|
||||
|
|
@ -41,7 +54,11 @@
|
|||
<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() }}".
|
||||
@if ($addressMode ?? false)
|
||||
Sie erhalten wieder Benachrichtigungen an diese Adresse.
|
||||
@else
|
||||
Sie erhalten wieder E-Mails vom Typ „{{ $notificationType->label() }}".
|
||||
@endif
|
||||
</h2>
|
||||
<p class="text-[13.5px] text-ink-2 leading-[1.6] m-0">
|
||||
Ihre Abmeldung wurde rückgängig gemacht.
|
||||
|
|
|
|||
|
|
@ -93,6 +93,17 @@ Route::prefix('e-mail-benachrichtigungen')
|
|||
->name('unsubscribe.one-click');
|
||||
Route::post('anmelden/{user}/{type}', [EmailUnsubscribeController::class, 'resubscribe'])
|
||||
->name('resubscribe');
|
||||
|
||||
// Roadmap 1d.3: Abmeldung für Empfänger ohne Konto (Pressekontakt,
|
||||
// Firmen-E-Mail einer API-Meldung) — adressbezogen, in der
|
||||
// Sperrliste geführt. Signiert, damit nur ein Link aus unserer Mail
|
||||
// eine Adresse abmelden kann.
|
||||
Route::get('abmelden-adresse', [EmailUnsubscribeController::class, 'showAddress'])
|
||||
->name('unsubscribe-address');
|
||||
Route::post('abmelden-adresse', [EmailUnsubscribeController::class, 'oneClickUnsubscribeAddress'])
|
||||
->name('unsubscribe-address.one-click');
|
||||
Route::post('anmelden-adresse', [EmailUnsubscribeController::class, 'resubscribeAddress'])
|
||||
->name('resubscribe-address');
|
||||
});
|
||||
|
||||
// ============================================================
|
||||
|
|
|
|||
113
tests/Feature/AddressUnsubscribeTest.php
Normal file
113
tests/Feature/AddressUnsubscribeTest.php
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
<?php
|
||||
|
||||
use App\Enums\EmailBounceKind;
|
||||
use App\Enums\EmailNotificationType;
|
||||
use App\Enums\EmailSuppressionReason;
|
||||
use App\Enums\Portal;
|
||||
use App\Mail\MagicLoginLink;
|
||||
use App\Mail\PressReleasePublished;
|
||||
use App\Models\Company;
|
||||
use App\Models\EmailSuppression;
|
||||
use App\Models\PressRelease;
|
||||
use App\Models\PressReleaseStatusLog;
|
||||
use App\Models\User;
|
||||
use App\Services\Auth\ContactAccessService;
|
||||
use App\Services\Mail\EmailSuppressionService;
|
||||
use App\Services\PressRelease\PressReleaseService;
|
||||
use Database\Seeders\RolesAndPermissionsSeeder;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\URL;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Abmeldung für Empfänger ohne Konto (Roadmap 1d.3): Pressekontakt oder
|
||||
* Firmen-E-Mail einer API-Meldung. Der Zustand liegt in der Sperrliste;
|
||||
* selbst angeforderte Anmeldelinks gehen weiter durch.
|
||||
*/
|
||||
beforeEach(function (): void {
|
||||
/** @var TestCase $this */
|
||||
$this->seed(RolesAndPermissionsSeeder::class);
|
||||
Mail::fake();
|
||||
});
|
||||
|
||||
function apiReleaseFor(string $companyEmail): PressRelease
|
||||
{
|
||||
$submitter = User::factory()->create(['is_active' => true, 'email' => 'api@connektar.example']);
|
||||
$company = Company::factory()->create(['portal' => Portal::Businessportal24->value, 'email' => $companyEmail, 'name' => 'Muster GmbH']);
|
||||
$release = PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(['user_id' => $submitter->id, 'company_id' => $company->id]);
|
||||
PressReleaseStatusLog::query()->create(['press_release_id' => $release->id, 'from_status' => 'draft', 'to_status' => 'review', 'source' => 'api']);
|
||||
|
||||
return $release;
|
||||
}
|
||||
|
||||
test('the notification to a recipient without an account carries an address-based unsubscribe link', function () {
|
||||
/** @var TestCase $this */
|
||||
$release = apiReleaseFor('presse@muster.example');
|
||||
|
||||
$mail = new PressReleasePublished($release->user, $release, 'Muster GmbH', 'presse@muster.example');
|
||||
|
||||
// Vorher zeigte der Link auf das Konto des Einlieferers — ein Klick des
|
||||
// Pressekontakts hätte connektar abgemeldet.
|
||||
expect($mail->unsubscribeUrl)->toContain('/abmelden-adresse?')
|
||||
->and($mail->unsubscribeUrl)->toContain('email=presse%40muster.example')
|
||||
->and($mail->unsubscribeUrl)->not->toContain('/abmelden/'.$release->user_id.'/');
|
||||
|
||||
// Der Einlieferer selbst behält den Konto-Link.
|
||||
$own = new PressReleasePublished($release->user, $release, null, $release->user->email);
|
||||
expect($own->unsubscribeUrl)->toContain('/abmelden/'.$release->user_id.'/');
|
||||
});
|
||||
|
||||
test('clicking the address link records an unsubscribe suppression and blocks further notifications', function () {
|
||||
/** @var TestCase $this */
|
||||
$release = apiReleaseFor('presse@muster.example');
|
||||
$url = URL::signedRoute('email-preferences.unsubscribe-address', ['email' => 'presse@muster.example', 'type' => EmailNotificationType::PressReleasePublished->value]);
|
||||
|
||||
$this->get($url)->assertOk()->assertSeeText('gehen keine Benachrichtigungen mehr')->assertSeeText('p***@muster.example');
|
||||
|
||||
$suppression = EmailSuppression::query()->where('email', 'presse@muster.example')->firstOrFail();
|
||||
expect($suppression->reason)->toBe(EmailSuppressionReason::Unsubscribed)
|
||||
->and($suppression->isActive())->toBeTrue();
|
||||
|
||||
// Nächste Benachrichtigung an diese Firma: keine Mail.
|
||||
app(PressReleaseService::class)->reject(tap($release)->update(['status' => 'review']), 'Grund');
|
||||
Mail::assertNothingQueued();
|
||||
});
|
||||
|
||||
test('an unsubscribed address still receives the login link it requests itself', function () {
|
||||
/** @var TestCase $this */
|
||||
apiReleaseFor('presse@muster.example');
|
||||
app(EmailSuppressionService::class)->unsubscribe('presse@muster.example');
|
||||
|
||||
Mail::fake();
|
||||
app(ContactAccessService::class)->requestMagicAccess('presse@muster.example');
|
||||
Mail::assertQueued(MagicLoginLink::class);
|
||||
|
||||
// …ein harter Bounce sperrt dagegen alles.
|
||||
app(EmailSuppressionService::class)->recordBounce('presse@muster.example', EmailBounceKind::Hard, 'imap', 'm-1');
|
||||
expect(app(EmailSuppressionService::class)->isSuppressed('presse@muster.example', includeUnsubscribed: false))->toBeTrue();
|
||||
});
|
||||
|
||||
test('the unsubscribe can be undone from the confirmation page, a tampered link cannot unsubscribe', function () {
|
||||
/** @var TestCase $this */
|
||||
$suppressions = app(EmailSuppressionService::class);
|
||||
$suppressions->unsubscribe('presse@muster.example');
|
||||
|
||||
$this->post(URL::signedRoute('email-preferences.resubscribe-address', ['email' => 'presse@muster.example', 'type' => 'press_release_published']))
|
||||
->assertOk()->assertSeeText('wieder Benachrichtigungen');
|
||||
expect($suppressions->isSuppressed('presse@muster.example'))->toBeFalse();
|
||||
|
||||
$this->get(route('email-preferences.unsubscribe-address', ['email' => 'opfer@example.org', 'type' => 'press_release_published', 'signature' => 'x']))
|
||||
->assertOk()->assertSeeText('Link ungültig');
|
||||
expect($suppressions->isSuppressed('opfer@example.org'))->toBeFalse();
|
||||
});
|
||||
|
||||
test('a hard-bounce suppression is not downgraded to an unsubscribe', function () {
|
||||
/** @var TestCase $this */
|
||||
$suppressions = app(EmailSuppressionService::class);
|
||||
$suppressions->recordBounce('tot@muster.example', EmailBounceKind::Hard, 'imap', 'm-2');
|
||||
|
||||
$suppressions->unsubscribe('tot@muster.example');
|
||||
|
||||
expect(EmailSuppression::query()->where('email', 'tot@muster.example')->first()->reason)->toBe(EmailSuppressionReason::HardBounce)
|
||||
->and($suppressions->resubscribe('tot@muster.example'))->toBeFalse();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue