presseportale/app/Services/Company/NewsroomClaimService.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

139 lines
4.6 KiB
PHP

<?php
namespace App\Services\Company;
use App\Enums\ConversionSource;
use App\Models\Company;
use App\Scopes\PortalScope;
use App\Services\Auth\ContactAccessService;
use App\Services\Mail\EmailSuppressionService;
use Illuminate\Support\Collection;
use Illuminate\Support\Str;
/**
* „Diesen Newsroom verwalten" (Roadmap 1b.2): Verwaltungszugang zu einer
* Firma über die hinterlegte Firmen-E-Mail, unabhängig davon, ob der
* Newsroom öffentlich ist.
*
* Der Unterschied zur allgemeinen Anmeldung per E-Mail-Link: Der Besucher
* gibt KEINE Adresse ein. Der Link geht an das, was an der Firma hinterlegt
* ist — die Firmen-E-Mail, sonst die Pressekontakte. Wer das Postfach hat,
* verwaltet die Firma; wer es nicht hat, erfährt nur eine maskierte Adresse.
* Kein neues Zugangssystem: Konto, Zuordnung und Login laufen durch
* `ContactAccessService`, genau wie bei der Anmeldung über pressekonto.
*
* Die Herkunft `profile_claim` steht auf dem Token — so misst die
* Konversionsleiter, wie viele Newsrooms über diesen Weg beansprucht werden.
*/
class NewsroomClaimService
{
public function __construct(
private readonly ContactAccessService $contactAccess,
private readonly EmailSuppressionService $suppressions,
) {}
/**
* Firma zu einem Slug auf diesen Portalen — auch ohne veröffentlichte
* Meldung, auch deaktiviert. Nicht aber 410-entfernt oder gelöscht: Die
* Firma existiert als Datensatz, aber nicht mehr als Newsroom.
*
* @param list<string> $portalValues
*/
public function findClaimable(array $portalValues, string $slug): ?Company
{
return Company::query()
->withoutGlobalScope(PortalScope::class)
->whereIn('portal', $portalValues)
->where('slug', $slug)
->whereNull('gone_at')
->first();
}
/**
* Empfänger des Anmeldelinks: die Firmen-E-Mail, sonst die Adressen der
* Pressekontakte. Bewusst nicht beides — ein Klick eines Fremden soll
* nicht fünf Postfächer treffen. Gesperrte Adressen (Bounce-Suppression,
* 1b.6) zählen nicht: Ein Newsroom, dessen einzige Adresse tot ist, ist
* nicht beanspruchbar — siehe isUnreachable().
*
* @return Collection<int, string>
*/
public function recipients(Company $company): Collection
{
$candidates = $this->candidateRecipients($company);
if ($candidates === []) {
return collect();
}
// 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))));
}
/**
* Es gäbe Adressen, aber alle sind gesperrt.
*/
public function isUnreachable(Company $company): bool
{
return $this->candidateRecipients($company) !== [] && $this->recipients($company)->isEmpty();
}
/**
* @return list<string>
*/
private function candidateRecipients(Company $company): array
{
$companyEmail = Company::emailKey($company->email);
if ($companyEmail !== null) {
return [$companyEmail];
}
$emails = [];
foreach ($company->contacts()->withoutGlobalScope(PortalScope::class)->pluck('email') as $email) {
$key = Company::emailKey($email);
if ($key !== null) {
$emails[$key] = $key;
}
}
return array_values($emails);
}
/**
* Verschickt die Anmeldelinks. Gibt zurück, an wie viele Adressen
* tatsächlich etwas ging — deaktivierte Konten bekommen nichts.
*/
public function request(Company $company, ?string $ip, ConversionSource $source = ConversionSource::ProfileClaim, ?int $landingPressReleaseId = null): int
{
$sent = 0;
foreach ($this->recipients($company) as $email) {
if ($this->contactAccess->requestMagicAccess($email, $ip, $source, $company->getKey(), $landingPressReleaseId)) {
$sent++;
}
}
return $sent;
}
/**
* @return Collection<int, string>
*/
public function maskedRecipients(Company $company): Collection
{
return $this->recipients($company)->map(fn (string $email): string => self::mask($email));
}
public static function mask(string $email): string
{
[$local, $domain] = array_pad(explode('@', $email, 2), 2, '');
return Str::substr($local, 0, 1).'***@'.$domain;
}
}