mail: Anrede an den tatsächlichen Empfänger binden

Bei Einreichungen über die API geht die Veröffentlichungs- oder
Ablehnungsmail nicht an das API-Konto, sondern an den Pressekontakt der
Meldung oder ersatzweise an die Firmen-Adresse. Die Anrede stammte aber
weiterhin vom Konto — der Empfänger wurde also mit dem Namen des
Einreichers begrüßt, bei connektar mit dem der Agentur.

Adresse und Name werden jetzt gemeinsam bestimmt. Ohne hinterlegten Namen
bleibt er leer und das Template grüßt neutral, statt einen falschen zu raten.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-09-01 11:07:23 +00:00
parent e634af0330
commit f6934f54e1
6 changed files with 61 additions and 9 deletions

View file

@ -26,10 +26,18 @@ class PressReleasePublished extends Mailable
*/ */
public array $portalLinks; public array $portalLinks;
/**
* Name für die Anrede — bei API-Einlieferungen der Pressekontakt bzw.
* die Firma, nie der Kontoname des API-Einlieferers.
*/
public string $recipientName;
public function __construct( public function __construct(
public readonly User $user, public readonly User $user,
public readonly PressRelease $pressRelease, public readonly PressRelease $pressRelease,
?string $recipientName = null,
) { ) {
$this->recipientName = $recipientName ?? $user->name;
$this->showUrl = $user->canAccessAdmin() $this->showUrl = $user->canAccessAdmin()
? route('admin.press-releases.show', $pressRelease->id) ? route('admin.press-releases.show', $pressRelease->id)
: route('me.press-releases.show', $pressRelease->id); : route('me.press-releases.show', $pressRelease->id);

View file

@ -18,11 +18,19 @@ class PressReleaseRejected extends Mailable
public string $editUrl; public string $editUrl;
/**
* Name für die Anrede — bei API-Einlieferungen der Pressekontakt bzw.
* die Firma, nie der Kontoname des API-Einlieferers.
*/
public string $recipientName;
public function __construct( public function __construct(
public readonly User $user, public readonly User $user,
public readonly PressRelease $pressRelease, public readonly PressRelease $pressRelease,
public readonly ?string $reason = null, public readonly ?string $reason = null,
?string $recipientName = null,
) { ) {
$this->recipientName = $recipientName ?? $user->name;
$this->editUrl = $user->canAccessAdmin() $this->editUrl = $user->canAccessAdmin()
? route('admin.press-releases.edit', $pressRelease->id) ? route('admin.press-releases.edit', $pressRelease->id)
: route('me.press-releases.edit', $pressRelease->id); : route('me.press-releases.edit', $pressRelease->id);

View file

@ -669,6 +669,7 @@ class PressReleaseService
// ist die Quelle des letzten Review-Uebergangs im Status-Log — der // ist die Quelle des letzten Review-Uebergangs im Status-Log — der
// Kontotyp ist nicht verlaesslich (connektar ist z. B. 'agency'). // Kontotyp ist nicht verlaesslich (connektar ist z. B. 'agency').
$recipient = $user->email; $recipient = $user->email;
$recipientName = $user->name;
$isApiSubmitter = $pressRelease->statusLogs() $isApiSubmitter = $pressRelease->statusLogs()
->where('to_status', PressReleaseStatus::Review->value) ->where('to_status', PressReleaseStatus::Review->value)
->orderByDesc('id') ->orderByDesc('id')
@ -676,10 +677,28 @@ class PressReleaseService
if ($isApiSubmitter) { if ($isApiSubmitter) {
// Scope-frei auflösen: der Mail-Versand läuft auch aus Jobs/ // Scope-frei auflösen: der Mail-Versand läuft auch aus Jobs/
// Kontexten ohne passenden Portal-Host (PortalScope). // Kontexten ohne passenden Portal-Host (PortalScope). Anrede und
$recipient = $pressRelease->contacts()->withoutGlobalScopes()->whereNotNull('email')->value('email') // Adresse gehören zusammen — sobald die Mail an Pressekontakt
?? Company::withoutGlobalScopes()->find($pressRelease->company_id)?->email // oder Firma geht, darf nie der Name des API-Kontos gegrüßt
?: $user->email; // werden. Ohne hinterlegten Namen bleibt $recipientName leer,
// das Template grüßt dann neutral.
$contact = $pressRelease->contacts()
->withoutGlobalScopes()
->whereNotNull('contacts.email')
->where('contacts.email', '!=', '')
->orderBy('contacts.id')
->first();
$company = Company::withoutGlobalScopes()->find($pressRelease->company_id);
$companyName = trim((string) $company?->name);
if ($contact) {
$recipient = $contact->email;
$recipientName = trim($contact->first_name.' '.$contact->last_name) ?: $companyName;
} elseif ($company && filled($company->email)) {
$recipient = $company->email;
$recipientName = $companyName;
}
} }
// Opt-out-Gate: Benachrichtigungs-Mails sind je Typ abbestellbar // Opt-out-Gate: Benachrichtigungs-Mails sind je Typ abbestellbar
@ -701,8 +720,8 @@ class PressReleaseService
} }
$mailable = match ($event) { $mailable = match ($event) {
'published' => new PressReleasePublished($user, $pressRelease), 'published' => new PressReleasePublished($user, $pressRelease, $recipientName),
'rejected' => new PressReleaseRejected($user, $pressRelease, $reason), 'rejected' => new PressReleaseRejected($user, $pressRelease, $reason, $recipientName),
default => null, default => null,
}; };

View file

@ -1,5 +1,5 @@
<x-emails.layout title="Ihre Pressemitteilung wurde veröffentlicht" preheader="Ihre Pressemitteilung wurde geprüft und veröffentlicht."> <x-emails.layout title="Ihre Pressemitteilung wurde veröffentlicht" preheader="Ihre Pressemitteilung wurde geprüft und veröffentlicht.">
<p>Hallo {{ $user->name }},</p> <p>{{ filled($recipientName) ? 'Hallo '.$recipientName.',' : 'Guten Tag,' }}</p>
<p>Ihre Pressemitteilung wurde geprüft und erfolgreich veröffentlicht.</p> <p>Ihre Pressemitteilung wurde geprüft und erfolgreich veröffentlicht.</p>

View file

@ -1,5 +1,5 @@
<x-emails.layout title="Ihre Pressemitteilung wurde abgelehnt" preheader="Ihre eingereichte Pressemitteilung konnte nicht veröffentlicht werden."> <x-emails.layout title="Ihre Pressemitteilung wurde abgelehnt" preheader="Ihre eingereichte Pressemitteilung konnte nicht veröffentlicht werden.">
<p>Hallo {{ $user->name }},</p> <p>{{ filled($recipientName) ? 'Hallo '.$recipientName.',' : 'Guten Tag,' }}</p>
<p>Ihre eingereichte Pressemitteilung konnte leider nicht veröffentlicht werden.</p> <p>Ihre eingereichte Pressemitteilung konnte leider nicht veröffentlicht werden.</p>
@ -8,7 +8,7 @@
@if($reason) @if($reason)
<div class="meta-danger"> <div class="meta-danger">
<strong>Ablehnungsgrund:</strong><br> <strong>Ablehnungsgrund:</strong><br>
{{ $reason }} {!! nl2br(e($reason)) !!}
</div> </div>
@else @else
<p>Bitte überarbeiten Sie den Inhalt und reichen Sie die Pressemitteilung erneut ein.</p> <p>Bitte überarbeiten Sie den Inhalt und reichen Sie die Pressemitteilung erneut ein.</p>

View file

@ -156,6 +156,23 @@ test('rejected mail uses customer edit url for customer recipients', function ()
}); });
}); });
test('rejected mail renders multi-line reason with line breaks', function () {
/** @var TestCase $this */
$author = User::factory()->create(['is_active' => true]);
$author->assignRole('customer');
$pr = PressRelease::factory()->create([
'user_id' => $author->id,
'status' => PressReleaseStatus::Rejected->value,
]);
$html = (new PressReleaseRejected($author, $pr, "Erster Punkt fehlt.\nZweiter Punkt fehlt.\n\nBitte überarbeiten."))->render();
expect($html)
->toContain('Erster Punkt fehlt.<br')
->toContain('Zweiter Punkt fehlt.<br');
});
test('admin can resubmit cycle creates two audit log entries', function () { test('admin can resubmit cycle creates two audit log entries', function () {
/** @var TestCase $this */ /** @var TestCase $this */
$author = User::factory()->create(['is_active' => true]); $author = User::factory()->create(['is_active' => true]);