presseportale/app/Services/Mail/BounceReference.php
Kevin Adametz d6e49c11c3 web/seo: Änderungsdatum unabhängig von Aufrufen, EN-URLs in Sitemap und Legacy-API, signierte Bounce-Referenz, Merge/Canonical konsistent (Umsetzungsaudit 29.09.: R-02 bis R-11, N17, N18, OPT-1, OPT-2; 02.10.: O01)
- R-02/K-03: Aufrufaggregation und Merge ändern updated_at nicht mehr; neue
  Spalte press_releases.content_changed_at (INSTANT), contentModifiedAt()
  für Sitemap-lastmod, dateModified und article:modified_time
- R-03: Sitemap und Legacy-view_url mit /en/ für englische Meldungen
- R-04/O01: X-Pk-Ref je Empfänger per HMAC signiert; auf eine Sperre zählt —
  hart wie weich — nur ein gültig signierter Rückläufer, unsignierte
  standardmäßig nur zur Sichtung (BOUNCE_UNSIGNED_KIND=policy)
- R-05/R-06: Unmerge setzt nur unveränderte Füllwerte zurück
  (company_merge_moves.merged_value), Canonical-Neubewertung nach Merge und
  Unmerge, assignAll setzt veraltete Verweise zurück
- R-07 bis R-11: Crawler zählen nicht als Aufruf, Mail-Klick gedrosselt und
  verzögert geschrieben, Claim-Zähler erst nach Versand, email_bounces nach
  395 Tagen gelöscht, Canonical-Portal über Kategorie-ID, llms-Cache geleert,
  Feed-Teaser ohne Doppel-Escaping, Merge-Ketten aufgelöst
- N17/N18: kein falsches „gelistet seit", Rollen im Indexbericht wie Sitemap
- OPT-1/OPT-2: topFeeds in SQL, purge-events löscht nach Grenz-ID

Enthält aus PressRelease.php auch die Card-Bild-Helfer (B-01) und
wasEverPublished()/neverPublished() (A-05), weil die Datei ungeteilt
committet ist.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-02 13:09:00 +00:00

124 lines
3.9 KiB
PHP

<?php
namespace App\Services\Mail;
/**
* Signierte Bounce-Referenz (Umsetzungsaudit 29.09.2026, R-04).
*
* Jede ausgehende Mail trägt `X-Pk-Ref: <referenz>; s=<sig>[,<sig>…]` — je
* Empfänger eine HMAC über Referenz und Empfängeradresse. Ein Zustellbericht
* bringt die Original-Header zurück; nur wenn die Signatur zur gemeldeten
* Adresse passt, stammt der Bericht nachweislich von einer Mail, die wir an
* genau diese Adresse geschickt haben. Erst dann darf ein harter Bounce
* sperren — sonst könnte jeder mit einer Mail ans Return-Path-Postfach
* beliebige Adressen sperren und damit Magic Links, Resets und Rechnungen
* abschneiden.
*
* Schlüssel: `bounces.signing_key` (BOUNCE_SIGNING_KEY), sonst aus APP_KEY
* abgeleitet. Ein Schlüsselwechsel entwertet nur die Signaturen bereits
* versendeter Mails — deren Rückläufer zählen dann weich.
*/
class BounceReference
{
/** Ohne fachliche Referenz der Mailable. */
public const DefaultReference = 'mail';
/** Mehr Signaturen schreibt kein Header; weitere Empfänger bleiben unsigniert. */
public const MaxRecipients = 20;
private const SignatureLength = 32;
/**
* @param list<string> $recipients
*/
public function sign(?string $reference, array $recipients): string
{
$reference = $this->plain($reference) ?? self::DefaultReference;
$signatures = collect($recipients)
->map(fn (string $email): ?string => EmailSuppressionService::normalise($email))
->filter()
->unique()
->take(self::MaxRecipients)
->map(fn (string $email): string => $this->signature($reference, $email))
->implode(',');
return $signatures === '' ? $reference : $reference.'; s='.$signatures;
}
/**
* Ob der Header eine gültige Signatur für genau diesen Empfänger trägt.
*/
public function verify(?string $header, string $recipient): bool
{
$reference = $this->plain($header);
$email = EmailSuppressionService::normalise($recipient);
if ($reference === null || $email === null) {
return false;
}
$expected = $this->signature($reference, $email);
foreach ($this->signatures($header) as $signature) {
if (hash_equals($expected, $signature)) {
return true;
}
}
return false;
}
/**
* Ob der Header überhaupt Signaturen trägt — unterscheidet „vor der
* Umstellung versendet" (keine) von „gefälscht oder verändert" (falsche).
*/
public function isSigned(?string $header): bool
{
return $this->signatures($header) !== [];
}
/**
* Die fachliche Referenz ohne Signaturteil (für Protokoll und Anzeige).
*/
public function plain(?string $header): ?string
{
if ($header === null) {
return null;
}
$reference = trim((string) preg_replace('/;\s*s=.*$/s', '', $header));
return $reference === '' ? null : $reference;
}
/**
* @return list<string>
*/
private function signatures(?string $header): array
{
if ($header === null || preg_match('/;\s*s=(.+)$/s', $header, $m) !== 1) {
return [];
}
$list = (string) preg_replace('/\s+/', '', $m[1]);
return array_values(array_filter(explode(',', $list), fn (string $sig): bool => preg_match('/^[0-9a-f]{'.self::SignatureLength.'}$/', $sig) === 1));
}
private function signature(string $reference, string $email): string
{
return substr(hash_hmac('sha256', $reference."\n".$email, $this->key()), 0, self::SignatureLength);
}
private function key(): string
{
$configured = (string) config('bounces.signing_key');
if ($configured !== '') {
return $configured;
}
return hash_hmac('sha256', 'pk-bounce-reference', (string) config('app.key'));
}
}