diff --git a/app/Http/Controllers/Web/ContactController.php b/app/Http/Controllers/Web/ContactController.php
index 8603bb78..1bf3f243 100644
--- a/app/Http/Controllers/Web/ContactController.php
+++ b/app/Http/Controllers/Web/ContactController.php
@@ -36,7 +36,9 @@ class ContactController extends Controller
'email' => ['required', 'email', 'max:255'],
'message' => ['required', 'string', 'min:10', 'max:5000'],
'topic' => ['nullable', Rule::enum(ContactMessageTopic::class)],
- 'page_url' => ['nullable', 'string', 'max:500'],
+ // Nur http/https: page_url wird im Admin-Backend als klickbarer Link
+ // gerendert — verhindert eingeschleuste javascript:-URIs (Stored XSS).
+ 'page_url' => ['nullable', 'url:http,https', 'max:500'],
], [
'name.required' => __('web.launcher.validation_name'),
'name.max' => __('web.launcher.validation_name'),
@@ -64,14 +66,14 @@ class ContactController extends Controller
'user_id' => $request->user()?->id,
]);
- Mail::to($this->recipient())->send(new ContactRequestSubmitted(
+ Mail::to($this->recipient())->queue((new ContactRequestSubmitted(
senderName: $validated['name'],
senderEmail: $validated['email'],
messageText: $validated['message'],
host: $request->getHost(),
topicLabel: $topic->label(),
pageUrl: $validated['page_url'] ?? null,
- ));
+ ))->afterCommit());
return $this->done();
}
diff --git a/resources/views/livewire/admin/contact-messages/show.blade.php b/resources/views/livewire/admin/contact-messages/show.blade.php
index 73eccbed..c7b7b2ab 100644
--- a/resources/views/livewire/admin/contact-messages/show.blade.php
+++ b/resources/views/livewire/admin/contact-messages/show.blade.php
@@ -24,22 +24,30 @@ new #[Layout('components.layouts.app'), Title('Anliegen · Kontakt')] class exte
public function markInProgress(): void
{
+ $this->authorize('compliance:manage');
+
$this->message()->update(['status' => ContactMessageStatus::InProgress->value]);
$this->notification = __('Status auf „In Bearbeitung" gesetzt.');
}
public function resolve(): void
{
+ $this->authorize('compliance:manage');
+
$this->finish(ContactMessageStatus::Resolved, __('Anliegen als erledigt markiert.'));
}
public function reject(): void
{
+ $this->authorize('compliance:manage');
+
$this->finish(ContactMessageStatus::Rejected, __('Anliegen abgelehnt.'));
}
public function reopen(): void
{
+ $this->authorize('compliance:manage');
+
$this->message()->update([
'status' => ContactMessageStatus::Open->value,
'resolved_by_user_id' => null,
@@ -163,8 +171,12 @@ new #[Layout('components.layouts.app'), Title('Anliegen · Kontakt')] class exte
@endif
diff --git a/tests/Feature/Web/ContactFormTest.php b/tests/Feature/Web/ContactFormTest.php
index b2d7a610..1a132360 100644
--- a/tests/Feature/Web/ContactFormTest.php
+++ b/tests/Feature/Web/ContactFormTest.php
@@ -71,7 +71,7 @@ test('a valid contact request sends a mail to the configured recipient', functio
->assertRedirect('https://businessportal24.test/de')
->assertSessionHas('contact-status');
- Mail::assertSent(ContactRequestSubmitted::class, function (ContactRequestSubmitted $mail) {
+ Mail::assertQueued(ContactRequestSubmitted::class, function (ContactRequestSubmitted $mail) {
return $mail->hasTo('redaktion@businessportal24.de')
&& $mail->hasReplyTo('paula@example.test')
&& $mail->senderName === 'Paula Presse';
@@ -90,7 +90,7 @@ test('without a configured brand address the mail falls back to the from address
'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'));
+ Mail::assertQueued(ContactRequestSubmitted::class, fn (ContactRequestSubmitted $mail) => $mail->hasTo('fallback@pressekonto.de'));
});
test('a contact request from the hub domain goes to the pressekonto brand address', function () {
@@ -104,7 +104,7 @@ test('a contact request from the hub domain goes to the pressekonto brand addres
'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('kontakt@pressekonto.de'));
+ Mail::assertQueued(ContactRequestSubmitted::class, fn (ContactRequestSubmitted $mail) => $mail->hasTo('kontakt@pressekonto.de'));
});
test('a contact request is stored in the contact queue', function () {
@@ -144,12 +144,28 @@ test('a widget submission stores topic and page url', function () {
->and($message->portal)->toBe('presseecho')
->and($message->page_url)->toBe('https://presseecho.test/de/suche');
- Mail::assertSent(ContactRequestSubmitted::class, function (ContactRequestSubmitted $mail) {
+ Mail::assertQueued(ContactRequestSubmitted::class, function (ContactRequestSubmitted $mail) {
return $mail->topicLabel === 'Fehlermeldung'
&& $mail->pageUrl === 'https://presseecho.test/de/suche';
});
});
+test('a javascript page_url is rejected (stored XSS guard)', function () {
+ /** @var TestCase $this */
+ Mail::fake();
+
+ $this->post('https://presseecho.test/de/kontakt', [
+ 'name' => 'Mallory',
+ 'email' => 'mallory@example.test',
+ 'message' => 'Ein ganz normal aussehender Text mit genug Zeichen.',
+ 'topic' => 'bug_report',
+ 'page_url' => 'javascript:alert(document.cookie)',
+ ])->assertSessionHasErrors('page_url');
+
+ expect(ContactMessage::query()->count())->toBe(0);
+ Mail::assertNothingOutgoing();
+});
+
test('an advertising enquiry from the ad slot stores the advertising topic', function () {
/** @var TestCase $this */
Mail::fake();
@@ -167,7 +183,7 @@ test('an advertising enquiry from the ad slot stores the advertising topic', fun
expect($message->topic)->toBe(ContactMessageTopic::Advertising)
->and($message->portal)->toBe('presseecho');
- Mail::assertSent(ContactRequestSubmitted::class, fn (ContactRequestSubmitted $mail) => $mail->topicLabel === 'Werbeanfrage');
+ Mail::assertQueued(ContactRequestSubmitted::class, fn (ContactRequestSubmitted $mail) => $mail->topicLabel === 'Werbeanfrage');
});
test('the spoke pages offer the advertising topic in the contact launcher', function () {
@@ -190,7 +206,7 @@ test('an unknown topic is rejected', function () {
])->assertSessionHasErrors(['topic']);
expect(ContactMessage::query()->count())->toBe(0);
- Mail::assertNothingSent();
+ Mail::assertNothingOutgoing();
});
test('honeypot submissions are silently accepted without sending mail', function () {
@@ -204,7 +220,7 @@ test('honeypot submissions are silently accepted without sending mail', function
'website' => 'https://spam.example',
])->assertSessionHas('contact-status');
- Mail::assertNothingSent();
+ Mail::assertNothingOutgoing();
expect(ContactMessage::query()->count())->toBe(0);
});
@@ -218,5 +234,5 @@ test('the contact form validates its inputs', function () {
'message' => 'kurz',
])->assertSessionHasErrors(['name', 'email', 'message']);
- Mail::assertNothingSent();
+ Mail::assertNothingOutgoing();
});