R2.1: Kontaktformular-Backend (Controller + Mailable + Brand-Empfaenger)

POST kontakt mit Validierung, Honeypot (vor der Validierung, stilles
Abnicken), IP-Rate-Limit (5/h) + throttle:10,1. Versand an
brand.contact_email (neue CONTACT_EMAIL_*-Keys, [EXTERN: echte
Adressen]), Fallback MAIL_FROM_ADDRESS; Reply-To = Absender.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-07-03 08:14:52 +00:00
parent 1e9c2f96ed
commit a4d054b3ab
7 changed files with 234 additions and 0 deletions

View file

@ -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().

View file

@ -0,0 +1,79 @@
<?php
namespace App\Http\Controllers\Web;
use App\Http\Controllers\Controller;
use App\Mail\ContactRequestSubmitted;
use App\Support\WebPortalContext;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\Support\Facades\RateLimiter;
use Illuminate\Validation\ValidationException;
/**
* Öffentliches Kontaktformular der Web-Domains. Versendet die Anfrage per
* Mail an die Brand-Kontaktadresse (config domains.*.brand.contact_email,
* Fallback: Absenderadresse der Anwendung). Kein Persistieren.
*/
class ContactController extends Controller
{
public function __invoke(Request $request): RedirectResponse
{
// Bot: ein ausgefülltes Honeypot-Feld → still und neutral abnicken,
// bewusst VOR der Validierung (sonst verrät die size:0-Regel die Falle).
if (filled($request->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();
}
}

View file

@ -0,0 +1,35 @@
<?php
namespace App\Mail;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
class ContactRequestSubmitted extends Mailable
{
use Queueable, SerializesModels;
public function __construct(
public readonly string $senderName,
public readonly string $senderEmail,
public readonly string $messageText,
public readonly string $host,
) {}
public function envelope(): Envelope
{
return new Envelope(
subject: 'Neue Kontaktanfrage über '.$this->host,
replyTo: [new Address($this->senderEmail, $this->senderName)],
);
}
public function content(): Content
{
return new Content(view: 'emails.contact.submitted');
}
}

View file

@ -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',

View file

@ -0,0 +1,29 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Neue Kontaktanfrage</title>
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; max-width: 600px; margin: 0 auto; padding: 20px; }
.meta { background: #f8fafc; border-left: 4px solid #2563eb; padding: 12px 16px; margin: 16px 0; }
.meta dt { font-weight: bold; color: #111; }
.meta dd { margin: 0 0 8px; }
.message { white-space: pre-line; border: 1px solid #eee; padding: 16px; }
.note { font-size: 13px; color: #666; border-top: 1px solid #eee; padding-top: 12px; margin-top: 24px; }
</style>
</head>
<body>
<p>Über das Kontaktformular auf <strong>{{ $host }}</strong> ist eine neue Anfrage eingegangen:</p>
<dl class="meta">
<dt>Name</dt>
<dd>{{ $senderName }}</dd>
<dt>E-Mail</dt>
<dd><a href="mailto:{{ $senderEmail }}">{{ $senderEmail }}</a></dd>
</dl>
<div class="message">{{ $messageText }}</div>
<p class="note">Antworten gehen direkt an die Absenderadresse (Reply-To).</p>
</body>
</html>

View file

@ -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');

View file

@ -0,0 +1,78 @@
<?php
use App\Mail\ContactRequestSubmitted;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
test('the contact page renders the form', function () {
/** @var TestCase $this */
$this->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();
});