diff --git a/.env.example b/.env.example index e9d12764..371e4699 100644 --- a/.env.example +++ b/.env.example @@ -79,6 +79,12 @@ GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= GOOGLE_REDIRECT_URI=https://pressekonto.com/auth/google/callback +# Empfänger des öffentlichen Kontaktformulars je Brand. Ohne Wert fällt der +# Versand auf MAIL_FROM_ADDRESS zurück. [EXTERN: echte Adressen eintragen] +CONTACT_EMAIL_PRESSEKONTO= +CONTACT_EMAIL_PRESSEECHO= +CONTACT_EMAIL_BUSINESSPORTAL= + # Abo-/Bestandsschutz-Gate (WS-5). Default (auch ohne Env-Key) ist true: # Einreichen zur Prüfung erfordert ein aktives Abo, einen bezahlten Einzelkauf # oder Bestandsschutz (grandfathered) — siehe User::hasActiveBooking(). diff --git a/app/Http/Controllers/Web/ContactController.php b/app/Http/Controllers/Web/ContactController.php new file mode 100644 index 00000000..b8c9c9b2 --- /dev/null +++ b/app/Http/Controllers/Web/ContactController.php @@ -0,0 +1,79 @@ +input('website')) || filled($request->input('homepage'))) { + return $this->done(); + } + + $validated = $request->validate([ + 'name' => ['required', 'string', 'max:255'], + 'email' => ['required', 'email', 'max:255'], + 'message' => ['required', 'string', 'min:10', 'max:5000'], + ]); + + $this->ensureNotRateLimited($request); + RateLimiter::hit($this->throttleKey($request), 3600); + + Mail::to($this->recipient())->send(new ContactRequestSubmitted( + senderName: $validated['name'], + senderEmail: $validated['email'], + messageText: $validated['message'], + host: $request->getHost(), + )); + + return $this->done(); + } + + private function done(): RedirectResponse + { + return back()->with('contact-status', __('Vielen Dank für Ihre Nachricht. Wir melden uns schnellstmöglich bei Ihnen zurück.')); + } + + private function recipient(): string + { + $brand = WebPortalContext::fromRequest()->domainConfig['brand'] ?? []; + + return $brand['contact_email'] ?? config('mail.from.address'); + } + + private function ensureNotRateLimited(Request $request): void + { + if (! RateLimiter::tooManyAttempts($this->throttleKey($request), 5)) { + return; + } + + $seconds = RateLimiter::availableIn($this->throttleKey($request)); + + throw ValidationException::withMessages([ + 'message' => __('Zu viele Anfragen. Bitte versuchen Sie es in :minutes Minuten erneut.', [ + 'minutes' => max(1, (int) ceil($seconds / 60)), + ]), + ]); + } + + private function throttleKey(Request $request): string + { + return 'contact-form|'.$request->ip(); + } +} diff --git a/app/Mail/ContactRequestSubmitted.php b/app/Mail/ContactRequestSubmitted.php new file mode 100644 index 00000000..72b38674 --- /dev/null +++ b/app/Mail/ContactRequestSubmitted.php @@ -0,0 +1,35 @@ +host, + replyTo: [new Address($this->senderEmail, $this->senderName)], + ); + } + + public function content(): Content + { + return new Content(view: 'emails.contact.submitted'); + } +} diff --git a/config/domains.php b/config/domains.php index 51f59e7d..55cd602c 100644 --- a/config/domains.php +++ b/config/domains.php @@ -53,6 +53,7 @@ return [ 'brand' => [ 'name' => 'presse', 'accent' => 'konto', + 'contact_email' => env('CONTACT_EMAIL_PRESSEKONTO'), 'tagline_short' => 'Publisher · Hub', 'tagline_long' => 'Der gemeinsame Publisher-Bereich für presseecho und businessportal24 – Pressemitteilungen schreiben, redaktionell prüfen lassen, auf beiden Reichweiten veröffentlichen.', 'footer_legal' => '© :year pressekonto · Alle Rechte vorbehalten', @@ -78,6 +79,7 @@ return [ 'brand' => [ 'name' => 'presse', 'accent' => 'echo', + 'contact_email' => env('CONTACT_EMAIL_PRESSEECHO'), 'tagline_short' => 'Pressemitteilungen · DACH', 'tagline_long' => 'Fachmeldungen aus Wirtschaft und Industrie. Redaktionell geprüft. Strukturiert distribuiert.', 'footer_legal' => '© :year presseecho · Alle Rechte vorbehalten', @@ -109,6 +111,7 @@ return [ 'brand' => [ 'name' => 'businessportal', 'accent' => '24', + 'contact_email' => env('CONTACT_EMAIL_BUSINESSPORTAL'), 'tagline_short' => 'Pressemitteilungen · DACH', 'tagline_long' => 'Veröffentlichungs-Portal für redaktionell geprüfte Pressemitteilungen aus Deutschland, Österreich und der Schweiz.', 'footer_legal' => '© :year businessportal24 · Alle Rechte vorbehalten', diff --git a/resources/views/emails/contact/submitted.blade.php b/resources/views/emails/contact/submitted.blade.php new file mode 100644 index 00000000..bdd5aee9 --- /dev/null +++ b/resources/views/emails/contact/submitted.blade.php @@ -0,0 +1,29 @@ + + +
+ +Über das Kontaktformular auf {{ $host }} ist eine neue Anfrage eingegangen:
+ + + + + +Antworten gehen direkt an die Absenderadresse (Reply-To).
+ + diff --git a/routes/web.php b/routes/web.php index 8b40c269..689fe1f0 100644 --- a/routes/web.php +++ b/routes/web.php @@ -3,6 +3,7 @@ use App\Enums\Portal; use App\Http\Controllers\PressReleasePreviewController; use App\Http\Controllers\Web\CategoryController; +use App\Http\Controllers\Web\ContactController; use App\Http\Controllers\Web\HomeController; use App\Http\Controllers\Web\NewsroomDetailController; use App\Http\Controllers\Web\NewsroomsController; @@ -188,6 +189,9 @@ Route::prefix('{edition}')->whereIn('edition', SetEdition::EDITIONS)->group(func // Statische Seiten Route::view('faq', 'web.faq')->name('faq'); Route::view('kontakt', 'web.kontakt')->name('kontakt'); + Route::post('kontakt', ContactController::class) + ->middleware('throttle:10,1') + ->name('kontakt.store'); Route::view('ueber-uns', 'web.ueber-uns')->name('ueber-uns'); Route::get('veroeffentlichen', VeroeffentlichenController::class)->name('veroeffentlichen'); Route::get('suche', SucheController::class)->name('suche'); diff --git a/tests/Feature/Web/ContactFormTest.php b/tests/Feature/Web/ContactFormTest.php new file mode 100644 index 00000000..e3849759 --- /dev/null +++ b/tests/Feature/Web/ContactFormTest.php @@ -0,0 +1,78 @@ +get('https://businessportal24.test/de/kontakt') + ->assertSuccessful() + ->assertSee('name="name"', false) + ->assertSee('name="email"', false) + ->assertSee('name="message"', false) + ->assertSee('Nachricht senden'); +}); + +test('a valid contact request sends a mail to the configured recipient', function () { + /** @var TestCase $this */ + Mail::fake(); + config()->set('domains.domains.businessportal24.brand.contact_email', 'redaktion@businessportal24.de'); + + $this->from('https://businessportal24.test/de/kontakt') + ->post('https://businessportal24.test/de/kontakt', [ + 'name' => 'Paula Presse', + 'email' => 'paula@example.test', + 'message' => 'Ich habe eine Frage zu Ihrer Plattform und bitte um Rückruf.', + ]) + ->assertRedirect('https://businessportal24.test/de/kontakt') + ->assertSessionHas('contact-status'); + + Mail::assertSent(ContactRequestSubmitted::class, function (ContactRequestSubmitted $mail) { + return $mail->hasTo('redaktion@businessportal24.de') + && $mail->hasReplyTo('paula@example.test') + && $mail->senderName === 'Paula Presse'; + }); +}); + +test('without a configured brand address the mail falls back to the from address', function () { + /** @var TestCase $this */ + Mail::fake(); + config()->set('domains.domains.businessportal24.brand.contact_email', null); + config()->set('mail.from.address', 'fallback@pressekonto.de'); + + $this->post('https://businessportal24.test/de/kontakt', [ + 'name' => 'Paula Presse', + 'email' => 'paula@example.test', + 'message' => 'Ich habe eine Frage zu Ihrer Plattform und bitte um Rückruf.', + ])->assertSessionHas('contact-status'); + + Mail::assertSent(ContactRequestSubmitted::class, fn (ContactRequestSubmitted $mail) => $mail->hasTo('fallback@pressekonto.de')); +}); + +test('honeypot submissions are silently accepted without sending mail', function () { + /** @var TestCase $this */ + Mail::fake(); + + $this->post('https://businessportal24.test/de/kontakt', [ + 'name' => 'Bot', + 'email' => 'bot@example.test', + 'message' => 'Automatisierter Inhalt mit ausreichend Länge für die Validierung.', + 'website' => 'https://spam.example', + ])->assertSessionHas('contact-status'); + + Mail::assertNothingSent(); +}); + +test('the contact form validates its inputs', function () { + /** @var TestCase $this */ + Mail::fake(); + + $this->post('https://businessportal24.test/de/kontakt', [ + 'name' => '', + 'email' => 'keine-mail', + 'message' => 'kurz', + ])->assertSessionHasErrors(['name', 'email', 'message']); + + Mail::assertNothingSent(); +});