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. --}} -

- Bearbeiten und verwalten können Sie die Pressemitteilung in Ihrem Konto:
- Pressemitteilung im pressekonto öffnen -

-

- 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.

+ Pressemitteilung bearbeiten + @endif {{-- Roadmap 1d.2 (E6: die Ablehnungsstrecke bleibt frei von Angeboten): ein Hinweis, was eine Mitteilung braucht, und der Weg zur Freigabe. --}} diff --git a/resources/views/livewire/auth/mail-access.blade.php b/resources/views/livewire/auth/mail-access.blade.php new file mode 100644 index 00000000..ee68c2b0 --- /dev/null +++ b/resources/views/livewire/auth/mail-access.blade.php @@ -0,0 +1,142 @@ + 'Ohne Passwort anmelden', 'eyebrow' => 'Zugang zu Ihrer Pressemitteilung', 'topRightLabel' => 'Lieber mit Passwort?', 'topRightLinkText' => 'Anmelden', 'topRightLinkHref' => '/login'])] class extends Component { + #[Locked] + public string $token = ''; + + #[Locked] + public string $source = ''; + + public string $maskedEmail = ''; + + public string $title = ''; + + public bool $sent = false; + + public function mount(MailAccessLink $links, ConversionRecorder $recorder): mixed + { + $data = $links->decode(request()->query('t')); + $pressRelease = $data === null ? null : $this->pressRelease($data['press_release_id']); + + abort_if($pressRelease === null, 404); + + $user = auth()->user(); + + if ($user !== null && mb_strtolower((string) $user->email) === $data['email'] && $user->can('view', $pressRelease)) { + return redirect()->route('me.press-releases.show', ['id' => $pressRelease->id]); + } + + $this->token = (string) request()->query('t'); + $this->source = $this->sourceFromRequest()->value; + $this->maskedEmail = NewsroomClaimService::mask($data['email']); + $this->title = (string) $pressRelease->title; + + $company = $this->company($pressRelease); + + if ($company !== null) { + $recorder->recordManagePageOpened(request(), $company, ConversionSource::parse($this->source), $pressRelease); + } + + return null; + } + + public function requestLink(MailAccessLink $links, ContactAccessService $contactAccess, ConversionRecorder $recorder): void + { + $data = $links->decode($this->token); + $pressRelease = $data === null ? null : $this->pressRelease($data['press_release_id']); + + if ($pressRelease === null) { + return; + } + + $key = 'mail-access|'.hash('sha256', $data['email']); + + if (RateLimiter::tooManyAttempts($key, 3)) { + $this->addError('link', __('Zu viele Anfragen. Bitte versuchen Sie es in :minutes Minuten erneut.', [ + 'minutes' => max(1, (int) ceil(RateLimiter::availableIn($key) / 60)), + ])); + + return; + } + + RateLimiter::hit($key, 3600); + + $source = ConversionSource::parse($this->source); + $sent = $contactAccess->requestMagicAccess($data['email'], request()->ip(), $source, $pressRelease->company_id, $pressRelease->id); + $company = $this->company($pressRelease); + + if ($sent && $company !== null) { + $recorder->record(ConversionEventType::AccessRequested, $source, $company, $pressRelease); + } + + $this->sent = true; + } + + private function sourceFromRequest(): ConversionSource + { + $source = ConversionSource::parse((string) request()->query('src')); + + return in_array($source, [ConversionSource::MailPublish, ConversionSource::MailReject], true) ? $source : ConversionSource::Organic; + } + + private function pressRelease(int $id): ?PressRelease + { + return PressRelease::query()->withoutGlobalScope(PortalScope::class)->find($id); + } + + private function company(PressRelease $pressRelease): ?Company + { + return $pressRelease->company_id === null + ? null + : Company::query()->withoutGlobalScope(PortalScope::class)->find($pressRelease->company_id); + } +}; ?> + +
+ @if ($sent) +
+ {{ __('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 () {