diff --git a/app/Mail/PressReleasePublished.php b/app/Mail/PressReleasePublished.php
index 0c869871..f1d13819 100644
--- a/app/Mail/PressReleasePublished.php
+++ b/app/Mail/PressReleasePublished.php
@@ -7,6 +7,7 @@ use App\Enums\EmailNotificationType;
use App\Mail\Concerns\Unsubscribable;
use App\Models\PressRelease;
use App\Models\User;
+use App\Services\Auth\MailAccessLink;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
@@ -39,6 +40,16 @@ class PressReleasePublished extends Mailable
*/
public ?string $manageNewsroomUrl = null;
+ /**
+ * Empfänger ohne Konto (API-Meldung an Pressekontakt oder Firmen-E-Mail,
+ * Roadmap 6.17): Adresse, an die diese Mail geht, und der Link, der mit
+ * einem Klick einen Anmeldelink an genau diese Adresse schickt. Null für
+ * Kunden, die selbst eingereicht haben — die kennen ihr Konto.
+ */
+ public ?string $accessEmail = null;
+
+ public ?string $accessUrl = null;
+
public function __construct(
public readonly User $user,
public readonly PressRelease $pressRelease,
@@ -72,6 +83,8 @@ class PressReleasePublished extends Mailable
// Geht die Mail an einen Empfänger ohne Konto (API-Meldung), meldet
// der Abmeldelink diese Adresse ab — nicht das Konto des Einlieferers.
if ($recipientEmail !== null && mb_strtolower(trim($recipientEmail)) !== mb_strtolower((string) $user->email)) {
+ $this->accessEmail = trim($recipientEmail);
+ $this->accessUrl = app(MailAccessLink::class)->url($this->accessEmail, $pressRelease, ConversionSource::MailPublish);
$this->configureAddressUnsubscribe($recipientEmail, EmailNotificationType::PressReleasePublished);
} else {
$this->configureUnsubscribe($user, EmailNotificationType::PressReleasePublished);
diff --git a/app/Mail/PressReleaseRejected.php b/app/Mail/PressReleaseRejected.php
index 78630327..8df42aaf 100644
--- a/app/Mail/PressReleaseRejected.php
+++ b/app/Mail/PressReleaseRejected.php
@@ -8,6 +8,7 @@ use App\Enums\Portal;
use App\Mail\Concerns\Unsubscribable;
use App\Models\PressRelease;
use App\Models\User;
+use App\Services\Auth\MailAccessLink;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
@@ -29,6 +30,16 @@ class PressReleaseRejected extends Mailable
*/
public string $recipientName;
+ /**
+ * Empfänger ohne Konto (API-Meldung, Roadmap 6.17): Adresse dieser Mail
+ * und Link, der einen Anmeldelink an sie schickt. Die Meldung kam über
+ * einen Verteiler — überarbeitet wird sie dort, nicht über unser Konto
+ * des Einlieferers; der Link führt deshalb nur zum Ansehen.
+ */
+ public ?string $accessEmail = null;
+
+ public ?string $accessUrl = null;
+
public function __construct(
public readonly User $user,
public readonly PressRelease $pressRelease,
@@ -53,6 +64,8 @@ class PressReleaseRejected extends Mailable
// Geht die Mail an einen Empfänger ohne Konto (API-Meldung), meldet
// der Abmeldelink diese Adresse ab — nicht das Konto des Einlieferers.
if ($recipientEmail !== null && mb_strtolower(trim($recipientEmail)) !== mb_strtolower((string) $user->email)) {
+ $this->accessEmail = trim($recipientEmail);
+ $this->accessUrl = app(MailAccessLink::class)->url($this->accessEmail, $pressRelease, ConversionSource::MailReject);
$this->configureAddressUnsubscribe($recipientEmail, EmailNotificationType::PressReleaseRejected);
} else {
$this->configureUnsubscribe($user, EmailNotificationType::PressReleaseRejected);
diff --git a/app/Services/Auth/MailAccessLink.php b/app/Services/Auth/MailAccessLink.php
new file mode 100644
index 00000000..b4d816d4
--- /dev/null
+++ b/app/Services/Auth/MailAccessLink.php
@@ -0,0 +1,63 @@
+addDays(self::VALID_DAYS), [
+ 't' => Crypt::encryptString((string) json_encode([
+ 'e' => mb_strtolower(trim($email)),
+ 'p' => $pressRelease->getKey(),
+ ])),
+ 'src' => $source->value,
+ 'pr' => $pressRelease->getKey(),
+ ]);
+ }
+
+ /**
+ * @return array{email: string, press_release_id: int}|null
+ */
+ public function decode(?string $token): ?array
+ {
+ if (blank($token)) {
+ return null;
+ }
+
+ try {
+ $data = json_decode(Crypt::decryptString((string) $token), true);
+ } catch (DecryptException) {
+ return null;
+ }
+
+ if (! is_array($data) || ! is_string($data['e'] ?? null) || ! filter_var($data['e'], FILTER_VALIDATE_EMAIL) || (int) ($data['p'] ?? 0) <= 0) {
+ return null;
+ }
+
+ return ['email' => $data['e'], 'press_release_id' => (int) $data['p']];
+ }
+}
diff --git a/resources/views/emails/press-release/published.blade.php b/resources/views/emails/press-release/published.blade.php
index d3bbfd18..a37ac756 100644
--- a/resources/views/emails/press-release/published.blade.php
+++ b/resources/views/emails/press-release/published.blade.php
@@ -24,25 +24,37 @@
@endif
- {{-- 2) Verwaltung im Konto (Login-Bereich), inkl. Hinweis auf die
- Anmeldung per Passwort oder Magic-Link. --}}
-
- Hinweis: Dieser Link führt in Ihren persönlichen Login-Bereich. Die Anmeldung ist
- dort mit Ihrem Passwort oder per Magic-Link (Anmelde-Link per E-Mail) möglich.
-
-
- {{-- 3) Verwaltungszugang zum Newsroom (Roadmap 1b.4, E4): ein Hinweis
- am Ende, kein Angebot — der Zugang ist kostenlos. --}}
- @if ($manageNewsroomUrl)
+ @if ($accessUrl)
+ {{-- 2) Empfänger ohne Konto (API-Meldung, Roadmap 6.17): ein Klick
+ schickt einen Anmeldelink an genau diese Adresse. Vorher stand
+ hier „in Ihrem Konto … mit Passwort oder Magic-Link“ — wer kein
+ Konto kennt, las daraus „geht nicht“. --}}
+
+
Etwas ändern oder die Aufrufe sehen?
+
+ Dafür brauchen Sie weder ein Konto noch ein Passwort. Wir schicken einen Anmeldelink an
+ {{ $accessEmail }}, die Adresse, an die diese Mail ging. Danach können Sie
+ die Meldung korrigieren, die Aufrufe Tag für Tag sehen{{ $pressRelease->company ? ' und den Newsroom von '.$pressRelease->company->name.' pflegen (Beschreibung, Logo, Pressekontakte)' : '' }}.
+ Das ist kostenlos.
+
+ Anmeldelink anfordern
+
+ @else
+ {{-- 2) Kunden mit eigenem Konto: direkt in den Kundenbereich. --}}
+ Im pressekonto öffnen
- Hinweis: Der Newsroom von {{ $pressRelease->company->name }} lässt sich kostenlos pflegen —
- Profil, Logo, Pressekontakte und die Aufrufe jeder Meldung im Verlauf. Den Zugang erhält die hinterlegte Firmen-E-Mail:
- Diesen Newsroom verwalten und Aufrufe einsehen
+ Dort sehen Sie die Aufrufe der Meldung und können sie korrigieren.
+
+ {{-- 3) Verwaltungszugang zum Newsroom (Roadmap 1b.4, E4): ein Hinweis
+ am Ende, kein Angebot — der Zugang ist kostenlos. --}}
+ @if ($manageNewsroomUrl)
+
+ Hinweis: Der Newsroom von {{ $pressRelease->company->name }} lässt sich kostenlos pflegen —
+ Profil, Logo, Pressekontakte und die Aufrufe jeder Meldung im Verlauf. Den Zugang erhält die hinterlegte Firmen-E-Mail:
+ Diesen Newsroom verwalten und Aufrufe einsehen
+
+ @endif
@endif
{{-- 4) Erstquelle gegen Kopie (Roadmap 1d, Anhang 17.09.2026): nur bei
diff --git a/resources/views/emails/press-release/rejected.blade.php b/resources/views/emails/press-release/rejected.blade.php
index 86c7bc74..66fa4317 100644
--- a/resources/views/emails/press-release/rejected.blade.php
+++ b/resources/views/emails/press-release/rejected.blade.php
@@ -14,7 +14,20 @@
Bitte überarbeiten Sie den Inhalt und reichen Sie die Pressemitteilung erneut ein.
@endif
- Pressemitteilung bearbeiten
+ @if ($accessUrl)
+ {{-- Empfänger ohne Konto (API-Meldung, Roadmap 6.17): ansehen ja,
+ überarbeitet und neu eingereicht wird über den Verteiler, über
+ den die Meldung kam. --}}
+
+ Meldung und Ablehnungsgrund können Sie ohne Konto und ohne Passwort ansehen: Wir schicken einen
+ Anmeldelink an {{ $accessEmail }}, die Adresse, an die diese Mail ging.
+ Eine überarbeitete Fassung reichen Sie bitte auf demselben Weg ein wie diese Meldung.
+
+ Anmeldelink anfordern
+ @else
+
Sie können die Meldung überarbeiten und erneut zur Prüfung einreichen.
+ {{ __('Wir haben einen Anmeldelink an :email geschickt. Er ist 15 Minuten gültig; nach dem Klick landen Sie direkt bei Ihrer Pressemitteilung.', ['email' => $maskedEmail]) }}
+
+ @else
+
+ {{ __('Sie brauchen weder ein Konto noch ein Passwort. Wir schicken einen Anmeldelink an die Adresse, an die unsere Mail ging:') }}
+
+
{{ $maskedEmail }}
+
{{ __('Pressemitteilung:') }} {{ $title }}
+
+ @error('link')
+
{{ $message }}
+ @enderror
+
+
+ @endif
+
+
+ {{ __('Nach der Anmeldung können Sie die Meldung ansehen und – je nach Stand – korrigieren, die Aufrufe verfolgen und den Newsroom Ihrer Firma pflegen. Das ist kostenlos.') }}
+
+
diff --git a/routes/auth.php b/routes/auth.php
index 50823e1e..2e32f8ea 100644
--- a/routes/auth.php
+++ b/routes/auth.php
@@ -27,6 +27,13 @@ Route::group(['middleware' => config('fortify.middleware', ['web'])], function (
->middleware(['guest:'.config('fortify.guard')])
->name('magic-link.request');
+ // Anmeldelink an die Adresse einer Kundenmail (Roadmap 6.17): nur über
+ // den signierten Link aus Veröffentlichungs- und Ablehnungsmail. Ohne
+ // `guest`: Ein angemeldeter Empfänger wird zur Meldung weitergeleitet.
+ Route::livewire('/zugang', 'auth.mail-access')
+ ->middleware(['signed', 'throttle:20,1'])
+ ->name('mail-access');
+
// Alte Pressekontakt-URL weiterleiten (Bestandslinks/Lesezeichen).
Route::redirect('/pressekontakt-zugang', '/anmeldelink');
diff --git a/tests/Feature/Auth/MailAccessTest.php b/tests/Feature/Auth/MailAccessTest.php
new file mode 100644
index 00000000..6d5c44be
--- /dev/null
+++ b/tests/Feature/Auth/MailAccessTest.php
@@ -0,0 +1,157 @@
+seed(RolesAndPermissionsSeeder::class);
+ Mail::fake();
+});
+
+function apiRelease(array $attributes = []): PressRelease
+{
+ $company = Company::factory()->businessportal24()->create([
+ 'name' => 'Firma GmbH', 'slug' => 'firma-gmbh', 'email' => 'presse@firma.de', 'is_active' => true,
+ ]);
+
+ return PressRelease::factory()->published()->forPortal(Portal::Businessportal24)->create(array_merge([
+ 'company_id' => $company->id, 'language' => 'de', 'title' => 'Firma GmbH eröffnet Standort',
+ ], $attributes));
+}
+
+/**
+ * @return array{t: string, src: string}
+ */
+function accessQuery(string $url): array
+{
+ parse_str((string) parse_url($url, PHP_URL_QUERY), $query);
+
+ return ['t' => (string) $query['t'], 'src' => (string) $query['src']];
+}
+
+// ------------------------------------------------------------------ Mails
+
+test('the publication mail offers a sign-in link to the address it went to when the recipient has no account', function () {
+ /** @var TestCase $this */
+ $release = apiRelease();
+ $mail = new PressReleasePublished($release->user, $release, 'Frau Muster', 'presse@firma.de', true);
+ $html = $mail->render();
+
+ expect($mail->accessEmail)->toBe('presse@firma.de')
+ ->and($mail->accessUrl)->toContain('/zugang?')
+ ->and($mail->accessUrl)->not->toContain('presse@firma.de')
+ ->and($mail->accessUrl)->not->toContain('presse%40firma.de')
+ ->and($html)->toContain('Etwas ändern oder die Aufrufe sehen?')
+ ->and($html)->toContain('weder ein Konto noch ein Passwort')
+ ->and($html)->toContain('presse@firma.de')
+ ->and($html)->toContain('den Newsroom von Firma GmbH pflegen')
+ ->and($html)->toContain('Anmeldelink anfordern')
+ ->and($html)->not->toContain('Im pressekonto öffnen')
+ ->and($html)->not->toContain('mit Ihrem Passwort oder per Magic-Link');
+});
+
+test('the rejection mail lets recipients without an account view the release, resubmission stays with the original channel', function () {
+ /** @var TestCase $this */
+ $release = apiRelease(['status' => 'rejected']);
+ $html = (new PressReleaseRejected($release->user, $release, 'Kein Anlass erkennbar.', 'Frau Muster', 'presse@firma.de'))->render();
+
+ expect($html)->toContain('ohne Konto und ohne Passwort ansehen')
+ ->and($html)->toContain('auf demselben Weg ein wie diese Meldung')
+ ->and($html)->toContain('Anmeldelink anfordern')
+ ->and($html)->not->toContain('Pressemitteilung bearbeiten');
+
+ // Wer selbst eingereicht hat, überarbeitet wie bisher im Konto.
+ $own = (new PressReleaseRejected($release->user, $release, 'Kein Anlass erkennbar.'))->render();
+ expect($own)->toContain('Pressemitteilung bearbeiten')
+ ->and($own)->not->toContain('Anmeldelink anfordern');
+});
+
+// --------------------------------------------------------- Zugangsseite
+
+test('the access page only opens with a valid signature and shows the address masked', function () {
+ /** @var TestCase $this */
+ $release = apiRelease();
+ $url = app(MailAccessLink::class)->url('presse@firma.de', $release, ConversionSource::MailPublish);
+
+ $this->get($url)
+ ->assertOk()
+ ->assertSeeText('p***@firma.de')
+ ->assertSeeText('Firma GmbH eröffnet Standort')
+ ->assertDontSeeText('presse@firma.de');
+
+ $this->get(str_replace('src=mail_publish', 'src=mail_reject', $url))->assertForbidden();
+
+ $opened = ConversionEvent::query()->where('type', ConversionEventType::ManagePageOpened->value)->sole();
+ expect($opened->source)->toBe(ConversionSource::MailPublish)
+ ->and($opened->press_release_id)->toBe($release->id);
+});
+
+test('one click sends the sign-in link to that address and the login lands on the release', function () {
+ /** @var TestCase $this */
+ $release = apiRelease();
+ $url = app(MailAccessLink::class)->url('presse@firma.de', $release, ConversionSource::MailPublish);
+
+ Livewire::withQueryParams(accessQuery($url))
+ ->test('auth.mail-access')
+ ->call('requestLink')
+ ->assertSet('sent', true)
+ ->assertSee('p***@firma.de');
+
+ $user = User::query()->where('email', 'presse@firma.de')->firstOrFail();
+ Mail::assertQueued(MagicLoginLink::class, fn (MagicLoginLink $mail): bool => $mail->user->is($user));
+
+ $link = MagicLink::query()->where('user_id', $user->id)->latest('id')->firstOrFail();
+ expect($link->source)->toBe(ConversionSource::MailPublish->value)
+ ->and($link->payload)->toBe(['company_id' => $release->company_id, 'press_release_id' => $release->id])
+ ->and(ConversionEvent::query()->where('type', ConversionEventType::AccessRequested->value)->sole()->company_id)->toBe($release->company_id);
+
+ $plain = str_replace('/magic-login/', '', parse_url(Mail::queued(MagicLoginLink::class)->first()->loginUrl, PHP_URL_PATH));
+ $this->get('https://pressekonto.test/magic-login/'.ltrim($plain, '/'))
+ ->assertRedirect(route('me.press-releases.show', ['id' => $release->id], absolute: false));
+});
+
+test('requests per address are limited', function () {
+ /** @var TestCase $this */
+ $release = apiRelease();
+ $query = accessQuery(app(MailAccessLink::class)->url('presse@firma.de', $release, ConversionSource::MailReject));
+
+ foreach (range(1, 3) as $i) {
+ Livewire::withQueryParams($query)->test('auth.mail-access')->call('requestLink')->assertHasNoErrors();
+ }
+
+ Livewire::withQueryParams($query)->test('auth.mail-access')->call('requestLink')->assertHasErrors('link');
+ Mail::assertQueuedCount(3);
+});
+
+test('a signed-in recipient goes straight to the release', function () {
+ /** @var TestCase $this */
+ $release = apiRelease();
+ $user = User::factory()->create(['email' => 'presse@firma.de', 'is_active' => true]);
+ $user->assignRole('customer');
+ $user->companies()->attach($release->company_id, ['role' => 'responsible']);
+
+ $this->actingAs($user)
+ ->get(app(MailAccessLink::class)->url('presse@firma.de', $release, ConversionSource::MailPublish))
+ ->assertRedirect(route('me.press-releases.show', ['id' => $release->id]));
+});
diff --git a/tests/Feature/Mail/EmailBrandingTest.php b/tests/Feature/Mail/EmailBrandingTest.php
index 0dea94c1..20f030b4 100644
--- a/tests/Feature/Mail/EmailBrandingTest.php
+++ b/tests/Feature/Mail/EmailBrandingTest.php
@@ -101,7 +101,7 @@ test('the published mail links both portals for a cross-portal press release', f
->toContain(config('domains.domain_businessportal_url').'/de/press-release/'.$pressRelease->slug);
});
-test('the published mail links the account area below the portal links with a login hint', function () {
+test('the published mail links the account area below the portal links for customers with an account', function () {
$pressRelease = PressRelease::factory()->published()->forPortal(Portal::Presseecho)->create();
$mailable = new PressReleasePublished($pressRelease->user, $pressRelease);
@@ -114,12 +114,11 @@ test('the published mail links the account area below the portal links with a lo
// zwei Parameter (`src`, `pr`), und Blade escapt das trennende `&` im
// HTML korrekt zu `&`.
->toContain(e($mailable->showUrl))
- ->toContain('Pressemitteilung im pressekonto öffnen')
- ->toContain('Login-Bereich')
- ->toContain('mit Ihrem Passwort oder per Magic-Link');
+ ->toContain('Im pressekonto öffnen')
+ ->not->toContain('Anmeldelink anfordern');
// Reihenfolge: erst der öffentliche Portal-Link, darunter der Konto-Link.
- expect(mb_strpos($html, $publicUrl))->toBeLessThan(mb_strpos($html, 'Pressemitteilung im pressekonto öffnen'));
+ expect(mb_strpos($html, $publicUrl))->toBeLessThan(mb_strpos($html, 'Im pressekonto öffnen'));
});
test('the termination mails use the hub ci layout', function () {