- 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>
124 lines
3.9 KiB
PHP
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'));
|
|
}
|
|
}
|