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:
parent
1e9c2f96ed
commit
a4d054b3ab
7 changed files with 234 additions and 0 deletions
|
|
@ -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().
|
||||
|
|
|
|||
79
app/Http/Controllers/Web/ContactController.php
Normal file
79
app/Http/Controllers/Web/ContactController.php
Normal 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();
|
||||
}
|
||||
}
|
||||
35
app/Mail/ContactRequestSubmitted.php
Normal file
35
app/Mail/ContactRequestSubmitted.php
Normal 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');
|
||||
}
|
||||
}
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
29
resources/views/emails/contact/submitted.blade.php
Normal file
29
resources/views/emails/contact/submitted.blade.php
Normal 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>
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
78
tests/Feature/Web/ContactFormTest.php
Normal file
78
tests/Feature/Web/ContactFormTest.php
Normal 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();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue