` und, wo es um eine * konkrete Meldung geht, `&pr=`. Beides wird hier einmal beim Eintritt * mitgeschrieben. * * Zur ID im Klartext: Sie ist fälschbar. Das ist hier vertretbar, weil sie * nichts freischaltet — sie ordnet ein Ereignis in der internen Statistik * einer Firma zu. Wer sie fälscht, verfälscht ausschließlich unsere eigene * Auswertung. Sobald der Verwaltungszugang über Magic Link steht (1b.2), * tragen die Mail-Links ohnehin ein Token, das Herkunft UND Meldung * fälschungssicher mitführt; der `pr`-Parameter entfällt dann. * * Nur Herkünfte aus Mails lösen aus. `organic` würde sonst bei jedem * getippten Parameter ein Ereignis erzeugen. Gedrosselt je IP und Meldung, * Link-Scanner ausgenommen, geschrieben nach der Antwort (R-08, * config/conversion.php `mail_clicks`). */ class RecordMailLinkClick { public function __construct(private readonly ConversionRecorder $recorder) {} public function handle(Request $request, Closure $next): Response { $source = ConversionSource::parse($request->query('src') !== null ? (string) $request->query('src') : null); if (! in_array($source, [ConversionSource::MailPublish, ConversionSource::MailReject], true)) { return $next($request); } // Nur echte Aufrufe. Mailclients und Sicherheitsscanner holen Links // vorab per HEAD oder mit eigener Kennung; das wäre kein Klick, // sondern eine Zustellung (Umsetzungsaudit 29.09.2026, R-08). if (! $request->isMethod('GET') || $this->recorder->isAutomatedVisit($request->userAgent())) { return $next($request); } $pressReleaseId = max(0, (int) $request->query('pr')); // Derselbe Klick (Doppelklick, erneutes Öffnen, Scanner mit Browser- // Kennung) zählt je IP und Meldung höchstens einmal im Zeitfenster. $key = 'mail-click:'.hash('sha256', (string) $request->ip()).':'.$source->value.':'.$pressReleaseId; $decay = max(1, (int) config('conversion.mail_clicks.throttle_minutes', 30)) * 60; if (! RateLimiter::attempt($key, 1, static fn (): bool => true, $decay)) { return $next($request); } // Nach der Antwort: Der Eintritt über den Mail-Link wartet nicht auf // die Statistik. $recorder = $this->recorder; defer(function () use ($recorder, $source, $pressReleaseId): void { $pressRelease = $this->pressRelease($pressReleaseId); $recorder->record( ConversionEventType::MailLinkClicked, $source, $recorder->companyOf($pressRelease), $pressRelease, ); }); return $next($request); } /** * Scope-frei aufgelöst: Der Klick landet je nach Link auf dem Hub oder auf * einem der beiden Spokes, der Portal-Scope würde die Meldung dort * wegfiltern. */ private function pressRelease(int $id): ?PressRelease { if ($id <= 0) { return null; } return PressRelease::query() ->withoutGlobalScope(PortalScope::class) ->find($id); } }