Anfrage als Seite statt mailto: der Knopf tat auf den meisten Geraeten sichtbar nichts
tests / pest (push) Failing after 10m7s Details
tests / assets (push) Successful in 30s Details
tests / release (push) Has been skipped Details

feat/neue-pakete
nexxo 2026-08-01 18:31:50 +02:00
parent d4f534fbbb
commit 055eb90876
8 changed files with 524 additions and 31 deletions

View File

@ -0,0 +1,78 @@
<?php
namespace App\Http\Controllers;
use App\Mail\ContactRequestMail;
use Illuminate\Http\RedirectResponse;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Mail;
use Illuminate\View\View;
/**
* „Sprechen Sie uns an" als Seite, nicht als `mailto:`.
*
* Der Knopf unter den Paketen zeigte auf `mailto:`, und der tut auf den meisten
* Geräten nichts Sichtbares: ohne eingerichtetes Mailprogramm passiert gar
* nichts, im Browser öffnet sich bestenfalls ein Dialog, den niemand erwartet
* hat. Das ist ausgerechnet an der Stelle fatal, an der das Selbstbedienen
* aufhört und ein Gespräch beginnen soll wer hier klickt, hat sich schon
* entschieden zu fragen.
*
* Eine Seite statt eines Modals: die öffentliche Website lädt kein Livewire,
* ein gewöhnlicher Formular-POST kommt also auch dann an, wenn JavaScript
* scheitert. Und eine Seite hat eine Adresse sie lässt sich aus einer Mail
* heraus verlinken, ein Modal nicht.
*/
class ContactController extends Controller
{
/** Themen, die das Formular kennt. Alles andere wird zu `general`. */
private const TOPICS = ['enterprise', 'general'];
public function show(Request $request): View
{
$topic = (string) $request->query('thema', 'general');
return view('contact', [
'topic' => in_array($topic, self::TOPICS, true) ? $topic : 'general',
'sent' => $request->session()->get('contact.sent', false),
]);
}
public function send(Request $request): RedirectResponse
{
$data = $request->validate([
'company' => ['nullable', 'string', 'max:120'],
'name' => ['required', 'string', 'max:120'],
'email' => ['required', 'email:rfc', 'max:190'],
'phone' => ['nullable', 'string', 'max:60'],
'message' => ['required', 'string', 'min:10', 'max:4000'],
'topic' => ['nullable', 'string', 'in:'.implode(',', self::TOPICS)],
// Honigtopf: ein Feld, das im Browser niemand sieht und ein
// Formularroboter trotzdem ausfüllt. `filled` statt einer eigenen
// Regel, damit die Meldung, die ein Mensch nie zu sehen bekommt,
// auch keine eigene Übersetzung braucht.
'website' => ['nullable', 'prohibited'],
], [], [
'name' => 'Name',
'email' => 'E-Mail-Adresse',
'message' => 'Nachricht',
]);
Mail::send(new ContactRequestMail([
'company' => $data['company'] ?? null,
'name' => $data['name'],
'email' => $data['email'],
'phone' => $data['phone'] ?? null,
'message' => $data['message'],
'topic' => $data['topic'] ?? 'general',
]));
// Zurück auf dieselbe Seite, mit einer Fahne in der Sitzung statt einer
// Kennung in der Adresse: ein Neuladen zeigt danach wieder das leere
// Formular und nicht dauerhaft eine Bestätigung für etwas, das längst
// erledigt ist.
return redirect()
->route('contact')
->with('contact.sent', true);
}
}

View File

@ -527,13 +527,17 @@ class LandingController extends Controller
]),
'body' => __('landing.enterprise_body'),
'cta' => __('landing.enterprise_cta'),
// No `contact` route exists (checked against routes/web.php), and
// adding a page or a route for one button would be new surface
// for a task that only has to react to a switch. mailto: is
// already how this exact page offers "talk to us" everywhere else
// on it — the FAQ aside and the closing section both point at the
// same address.
'href' => 'mailto:office@clupilot.com',
// Bis hierher stand hier `mailto:office@clupilot.com`, mit der
// Begründung, es gebe keine Kontaktseite und eine anzulegen sei zu
// viel für einen Knopf. Der Knopf tat damit auf den meisten Geräten
// sichtbar nichts — ohne eingerichtetes Mailprogramm passiert
// schlicht gar nichts. Ausgerechnet an der Stelle, an der das
// Selbstbedienen aufhört und jemand von sich aus fragen will.
//
// Mit `thema=enterprise`, damit die Seite weiß, wonach gefragt wird:
// sie stellt dann die Frage nach Speicher und Personenzahl, statt
// ein leeres Feld hinzustellen.
'href' => route('contact', ['thema' => 'enterprise']),
];
}

View File

@ -0,0 +1,80 @@
<?php
namespace App\Mail;
use App\Mail\Concerns\SendsFromMailbox;
use App\Services\Mail\MailPurpose;
use Illuminate\Bus\Queueable;
use Illuminate\Contracts\Queue\ShouldQueue;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Address;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
use Illuminate\Queue\SerializesModels;
/**
* Was jemand über das Anfrageformular der Website geschrieben hat.
*
* Die einzige Mail, die von DRAUSSEN nach drinnen läuft: alles andere hier
* schickt der Betrieb an einen Kunden. Deshalb baut sie ihren Umschlag selbst,
* statt `mailboxEnvelope()` zu benutzen das setzt Reply-To auf das Postfach,
* aus dem gesendet wird, und hier soll die Antwort an den Fragenden gehen, nicht
* an uns selbst.
*
* Gesendet wird trotzdem AUS dem Support-Postfach, nicht aus der Adresse des
* Fragenden: eine Mail, die vorgibt von fremder Domain zu kommen, scheitert an
* SPF und DKIM und landet im Spam ausgerechnet die eine Mail, die niemand
* verpassen darf.
*/
class ContactRequestMail extends Mailable implements ShouldQueue
{
use Queueable, SendsFromMailbox, SerializesModels;
/**
* @param array{company: ?string, name: string, email: string, phone: ?string, message: string, topic: ?string} $enquiry
*/
public function __construct(public array $enquiry)
{
$this->mailer('cp_'.MailPurpose::SUPPORT);
}
public function envelope(): Envelope
{
[$from] = $this->mailboxAddresses(MailPurpose::SUPPORT);
// Ohne eingerichtetes Postfach fällt beides auf die Absenderadresse des
// Frameworks zurück. Eine Anfrage still verschwinden zu lassen, weil
// niemand ein Postfach angelegt hat, wäre der teuerste Fehler auf dieser
// Seite — sie ist der Grund, warum es die Seite gibt.
$house = $from?->address ?: (string) config('mail.from.address');
return new Envelope(
from: $from ?: new Address($house),
to: [new Address($house)],
// Antworten geht an den Fragenden. Das ist der ganze Zweck: der
// Betreiber drückt „Antworten" und schreibt der Person, nicht sich.
replyTo: [new Address($this->enquiry['email'], $this->enquiry['name'])],
subject: $this->subjectLine(),
);
}
public function content(): Content
{
return new Content(view: 'mail.contact-request', with: [
'enquiry' => $this->enquiry,
]);
}
/**
* Der Betreff trägt, wonach gefragt wird, und von wem beides in der
* Postfachliste sichtbar, ohne die Mail zu öffnen.
*/
private function subjectLine(): string
{
$who = $this->enquiry['company'] ?: $this->enquiry['name'];
return $this->enquiry['topic'] === 'enterprise'
? 'Anfrage: eigene Maschine — '.$who
: 'Anfrage über die Website — '.$who;
}
}

View File

@ -0,0 +1,114 @@
{{--
„Sprechen Sie uns an."
Die Seite, auf der das Selbstbedienen aufhört. Wer hier landet, hat sich
schon entschieden zu fragen also steht hier ein Formular und keine zweite
Verkaufsseite. Vorher zeigte der Knopf auf `mailto:`, was auf den meisten
Geräten sichtbar gar nichts tut. Siehe App\Http\Controllers\ContactController.
--}}
<x-layouts.site title="Anfrage — CluPilot Cloud" robots="noindex">
<main class="mx-auto w-full max-w-[860px] px-5 pt-14 pb-24 sm:px-8">
<header class="rv">
<p class="lbl">Anfrage</p>
<h1 class="mt-3 text-[clamp(2rem,4vw,2.8rem)] font-bold leading-[1.06] tracking-[-0.035em] text-ink">
@if ($topic === 'enterprise')
Eine eigene Maschine.
@else
Sprechen Sie uns an.
@endif
</h1>
<p class="mt-4 max-w-[58ch] text-md leading-relaxed text-muted">
@if ($topic === 'enterprise')
Sagen Sie uns, wie viel Speicher Sie brauchen und wie viele Menschen damit
arbeiten. Wir rechnen Ihnen einen Server durch, auf dem nur Ihre Cloud läuft
und melden uns am selben Werktag.
@else
Schreiben Sie uns, was Sie vorhaben. Wir antworten am selben Werktag,
auf Deutsch, ohne Warteschleife.
@endif
</p>
</header>
@if ($sent)
{{-- Der Bestätigungszustand ersetzt das Formular, statt darüber zu
stehen: ein Formular, das nach dem Absenden weiter dasteht, lädt
zum zweiten Absenden ein. --}}
<div class="rv mt-10 rounded-xl border border-accent-border bg-accent-subtle p-7">
<h2 class="text-lg font-bold tracking-[-0.02em] text-ink">Ihre Anfrage ist da.</h2>
<p class="mt-2 max-w-[58ch] text-sm leading-relaxed text-body">
Wir haben sie bekommen und melden uns am selben Werktag. Eine Kopie geht
nicht automatisch an Sie falls Sie etwas nachreichen möchten, antworten
Sie einfach auf unsere Rückmeldung.
</p>
<a href="{{ route('home') }}" class="mt-5 inline-flex items-center gap-2 text-sm font-medium text-accent-text underline-offset-4 hover:underline">
Zurück zur Übersicht
</a>
</div>
@else
<form method="POST" action="{{ route('contact.send') }}" class="rv mt-10">
@csrf
<input type="hidden" name="topic" value="{{ $topic }}">
{{-- Der Honigtopf. Aus dem Lesefluss genommen statt display:none
ein Feld, das gar nicht existiert, füllt auch kein Roboter aus,
und genau das Ausfüllen ist hier das Erkennungszeichen.
aria-hidden und tabindex=-1, damit ein Screenreader und die
Tabulatortaste es überspringen. --}}
<div class="absolute left-[-9999px] top-auto h-px w-px overflow-hidden" aria-hidden="true">
<label for="website">Website (bitte frei lassen)</label>
<input type="text" id="website" name="website" tabindex="-1" autocomplete="off" value="">
</div>
<div class="overflow-hidden rounded-xl border border-line bg-surface shadow-xs">
@foreach ([
['company', 'Firma', 'text', false, 'organization'],
['name', 'Ihr Name', 'text', true, 'name'],
['email', 'E-Mail', 'email', true, 'email'],
['phone', 'Telefon (optional)', 'tel', false, 'tel'],
] as [$field, $label, $type, $required, $autocomplete])
<div class="border-b border-line px-6 py-5">
<label for="{{ $field }}" class="block text-sm font-medium text-ink">
{{ $label }}@if ($required)<span class="text-danger" aria-hidden="true"> *</span>@endif
</label>
<input type="{{ $type }}" id="{{ $field }}" name="{{ $field }}"
value="{{ old($field) }}" autocomplete="{{ $autocomplete }}"
@if ($required) required @endif
class="mt-2 block w-full rounded border border-line bg-surface px-3 py-2 text-sm text-ink transition focus:border-accent-border focus:outline-none">
@error($field)
<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>
@enderror
</div>
@endforeach
<div class="px-6 py-5">
<label for="message" class="block text-sm font-medium text-ink">
Ihre Nachricht<span class="text-danger" aria-hidden="true"> *</span>
</label>
<textarea id="message" name="message" rows="7" required
placeholder="{{ $topic === 'enterprise'
? 'Wie viel Speicher brauchen Sie, und wie viele Menschen arbeiten damit?'
: 'Worum geht es?' }}"
class="mt-2 block w-full rounded border border-line bg-surface px-3 py-2 text-sm leading-relaxed text-ink transition focus:border-accent-border focus:outline-none">{{ old('message') }}</textarea>
@error('message')
<p class="mt-1.5 text-xs text-danger">{{ $message }}</p>
@enderror
</div>
</div>
<div class="mt-5 flex flex-wrap items-center justify-between gap-4">
<p class="max-w-[46ch] text-xs leading-relaxed text-muted">
Wir benutzen Ihre Angaben nur, um Ihre Anfrage zu beantworten.
Näheres in der <a href="{{ route('legal.datenschutz') }}" class="underline underline-offset-2 hover:text-body">Datenschutzerklärung</a>.
</p>
<button type="submit" class="inline-flex min-h-[46px] items-center justify-center rounded-[10px] bg-ink px-[22px] text-[15px] font-semibold leading-none text-white transition hover:opacity-90">
Anfrage senden
</button>
</div>
</form>
@endif
</main>
</x-layouts.site>

View File

@ -818,8 +818,10 @@
<p class="mt-5 max-w-[36ch] text-md leading-relaxed text-muted">
Steht Ihre Frage nicht dabei, schreiben Sie uns wir antworten am selben Werktag.
</p>
<a href="mailto:office&#64;clupilot.com" class="mt-5 inline-flex items-center gap-2 text-sm font-medium text-accent-text underline-offset-4 hover:underline">
<x-ui.icon name="mail" class="size-4" />office&#64;clupilot.com
{{-- Auf die Anfrageseite, nicht auf `mailto:`. Der Link zeigte die
Adresse und tat beim Klicken auf den meisten Geräten nichts. --}}
<a href="{{ route('contact') }}" class="mt-5 inline-flex items-center gap-2 text-sm font-medium text-accent-text underline-offset-4 hover:underline">
<x-ui.icon name="mail" class="size-4" />Frage stellen
</a>
</div>
@ -881,7 +883,7 @@
<a href="{{ route('login') }}" class="inline-flex min-h-[46px] items-center justify-center gap-2 rounded-[10px] bg-white px-[22px] text-[15px] font-semibold leading-none text-accent-active transition hover:bg-accent-subtle">
Jetzt buchen
</a>
<a href="mailto:office&#64;clupilot.com" class="inline-flex min-h-[46px] items-center justify-center gap-2 rounded-[10px] border border-white/40 px-[22px] text-[15px] font-semibold leading-none text-white transition hover:bg-white/10">
<a href="{{ route('contact') }}" class="inline-flex min-h-[46px] items-center justify-center gap-2 rounded-[10px] border border-white/40 px-[22px] text-[15px] font-semibold leading-none text-white transition hover:bg-white/10">
<x-ui.icon name="mail" class="size-4" />Frage stellen
</a>
</div>

View File

@ -0,0 +1,37 @@
<x-mail.layout
heading="Anfrage über die Website"
:preheader="\Illuminate\Support\Str::limit($enquiry['message'], 90)"
:greeting="$enquiry['topic'] === 'enterprise'
? 'Jemand fragt nach einer eigenen Maschine.'
: 'Jemand hat das Anfrageformular ausgefüllt.'"
>
<tr><td style="padding:0 24px 8px 24px;">
{{-- Wer fragt, zuerst der Betreiber entscheidet an dieser Tabelle, ob er
gleich anruft oder zurückschreibt. --}}
<table role="presentation" cellpadding="0" cellspacing="0" border="0" width="100%">
@foreach ([
'Firma' => $enquiry['company'],
'Name' => $enquiry['name'],
'E-Mail' => $enquiry['email'],
'Telefon' => $enquiry['phone'],
] as $label => $value)
@if (filled($value))
<tr>
<td style="padding:0 0 6px 0;font-size:13px;line-height:20px;color:#8a8a95;width:90px;vertical-align:top;">{{ $label }}</td>
<td style="padding:0 0 6px 0;font-size:14px;line-height:20px;color:#43434e;">{{ $value }}</td>
</tr>
@endif
@endforeach
</table>
</td></tr>
<tr><td style="padding:16px 24px 32px 24px;">
{{-- Die Worte des Fragenden, wie getippt. nl2br über einer escapeten
Zeichenkette, niemals {!! !!}: dieser Text kommt aus einem öffentlichen
Formular, das jeder im Internet abschicken kann. Markup von dort darf
nicht in eine Mail geraten, die unsere Adresse als Absender trägt. --}}
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{!! nl2br(e($enquiry['message'])) !!}</p>
</td></tr>
</x-mail.layout>

View File

@ -1,23 +1,40 @@
<?php
use App\Http\Controllers\BootstrapArchiveController;
use App\Http\Controllers\CheckoutController;
use App\Http\Controllers\ContactController;
use App\Http\Controllers\ImpersonationController;
use App\Http\Controllers\LandingController;
use App\Http\Controllers\PublicFileController;
use App\Http\Controllers\StatusController;
use App\Http\Controllers\StripeWebhookController;
use App\Livewire\Admin;
use App\Livewire\Billing;
use App\Livewire\Auth\ForgotPassword;
use App\Livewire\Auth\Login;
use App\Livewire\Auth\Register;
use App\Livewire\Auth\ResetPassword;
use App\Livewire\Auth\TwoFactorChallenge;
use App\Livewire\Auth\VerifyEmail;
use App\Livewire\Backups;
use App\Livewire\Billing;
use App\Livewire\Checkout;
use App\Livewire\Cloud;
use App\Livewire\CustomDomain;
use App\Livewire\Dashboard;
use App\Livewire\Invoices;
use App\Livewire\Order;
use App\Livewire\Support;
use App\Livewire\Users;
use App\Models\Customer;
use App\Models\Invoice;
use App\Services\Billing\InvoiceRenderer;
use App\Services\Legal\ProcessingAgreement;
use App\Support\AdminArea;
use App\Support\Settings;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Route;
use Illuminate\Support\Facades\Storage;
use Laravel\Fortify\Http\Controllers\RegisteredUserController;
/*
| The operator console, registered BEFORE anything else in this file.
@ -241,8 +258,18 @@ $publicSite = function () {
// because it has already gone out in mail footers.
Route::get('/sicherheit', fn () => redirect()->route('security', status: 301));
// „Sprechen Sie uns an" als Seite. Die Knöpfe darauf zeigten vorher auf
// `mailto:`, das auf den meisten Geräten sichtbar nichts tut — siehe
// ContactController. Der Versand ist gedrosselt: das Formular steht offen
// im Internet und schickt eine Mail, ist also ohne Bremse ein Versandweg
// für jeden, der ihn findet.
Route::get('/contact', [ContactController::class, 'show'])->name('contact');
Route::post('/contact', [ContactController::class, 'send'])
->middleware('throttle:5,10')
->name('contact.send');
Route::get('/robots.txt', function () {
$body = App\Support\Settings::bool('site.public', true)
$body = Settings::bool('site.public', true)
? "User-agent: *\nAllow: /\n"
: "User-agent: *\nDisallow: /\n";
@ -283,18 +310,18 @@ $portal = function () {
// hostname would post to a route that answers there.
Route::middleware('guest')->group(function () {
Route::get('/login', Login::class)->name('login');
Route::get('/register', \App\Livewire\Auth\Register::class)->name('register');
Route::get('/register', Register::class)->name('register');
// Registration POST goes through Fortify's controller but with our own
// registration-scoped throttle (Fortify's built-in route has none).
Route::post('/register', [\Laravel\Fortify\Http\Controllers\RegisteredUserController::class, 'store'])
Route::post('/register', [RegisteredUserController::class, 'store'])
->middleware('throttle:registration')
->name('register.store');
// Fortify registers the two POST endpoints; with views off it
// registers no GET routes at all, so the pages are ours under its
// names — every framework redirect and the reset mail resolve
// `password.request` and `password.reset`.
Route::get('/forgot-password', \App\Livewire\Auth\ForgotPassword::class)->name('password.request');
Route::get('/reset-password/{token}', \App\Livewire\Auth\ResetPassword::class)->name('password.reset');
Route::get('/forgot-password', ForgotPassword::class)->name('password.request');
Route::get('/reset-password/{token}', ResetPassword::class)->name('password.reset');
Route::get('/two-factor-challenge', TwoFactorChallenge::class)->name('two-factor.login');
});
@ -307,7 +334,7 @@ $portal = function () {
// Signed in, but the address has not been confirmed yet. Fortify would own
// this page, but 'views' => false — it keeps the verify and resend actions
// and the app renders its own screens (R1/R2).
Route::get('/email/verify', \App\Livewire\Auth\VerifyEmail::class)
Route::get('/email/verify', VerifyEmail::class)
->middleware('auth')
->name('verification.notice');
@ -322,7 +349,7 @@ $portal = function () {
Route::middleware(['auth', 'verified', 'customer.active'])->group(function () {
Route::get('/dashboard', Dashboard::class)->name('dashboard');
Route::get('/cloud', Cloud::class)->name('cloud');
Route::get('/domain', \App\Livewire\CustomDomain::class)->name('domain');
Route::get('/domain', CustomDomain::class)->name('domain');
Route::get('/users', Users::class)->name('users');
Route::get('/backups', Backups::class)->name('backups');
Route::get('/invoices', Invoices::class)->name('invoices');
@ -339,17 +366,17 @@ $portal = function () {
// 404 for somebody else's document, which is also the right answer: it
// tells a stranger nothing about whether the number exists.
Route::get('/invoices/{uuid}/pdf', function (string $uuid) {
$customer = \App\Models\Customer::forUser(auth()->user());
$customer = Customer::forUser(auth()->user());
abort_if($customer === null, 404);
$invoice = \App\Models\Invoice::query()
$invoice = Invoice::query()
->where('customer_id', $customer->id)
->where('uuid', $uuid)
->firstOrFail();
return response()->streamDownload(
fn () => print (app(\App\Services\Billing\InvoiceRenderer::class)->forInvoice($invoice)),
fn () => print (app(InvoiceRenderer::class)->forInvoice($invoice)),
$invoice->number.'.pdf',
['Content-Type' => 'application/pdf'],
);
@ -361,12 +388,12 @@ $portal = function () {
Route::get('/processing-agreement/{which}', function (string $which) {
abort_unless(in_array($which, ['agreement', 'measures'], true), 404);
$version = app(App\Services\Legal\ProcessingAgreement::class)->current();
$version = app(ProcessingAgreement::class)->current();
$path = $version === null
? null
: ($which === 'agreement' ? $version->agreement_path : $version->measures_path);
abort_if($path === null || ! Illuminate\Support\Facades\Storage::disk('local')->exists($path), 404);
abort_if($path === null || ! Storage::disk('local')->exists($path), 404);
// Inline to read, attachment to keep — and the file that lands in
// somebody's downloads folder carries the VERSION in its name, so
@ -374,15 +401,15 @@ $portal = function () {
$name = ($which === 'agreement' ? 'CluPilot-AV-Vertrag-' : 'CluPilot-TOM-').$version->version.'.pdf';
return request()->boolean('download')
? Illuminate\Support\Facades\Storage::disk('local')->download($path, $name)
: Illuminate\Support\Facades\Storage::disk('local')->response($path, $name, [
? Storage::disk('local')->download($path, $name)
: Storage::disk('local')->response($path, $name, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'inline; filename="'.$name.'"',
]);
})->name('dpa.file');
Route::get('/billing', Billing::class)->name('billing');
Route::get('/settings', \App\Livewire\Settings::class)->name('settings');
Route::get('/settings', App\Livewire\Settings::class)->name('settings');
Route::get('/support', Support::class)->name('support');
// Buying a package. The page is a page like any other; the POST leaves
@ -393,18 +420,18 @@ $portal = function () {
// address is not a billing detail to be caught at checkout, it is an
// account that may not belong to the person holding it — and the
// credentials for the finished cloud are sent to that address.
Route::get('/order', \App\Livewire\Order::class)->name('order');
Route::get('/order', Order::class)->name('order');
// The order, before it is placed. There was no such page: the order page
// posted straight to Stripe, so the last thing a customer saw in this
// product's design was a grid of packages and the next thing was
// somebody else's card form. GET, with the package and the term in the
// address, so it survives a reload.
Route::get('/checkout', \App\Livewire\Checkout::class)->name('checkout');
Route::post('/checkout', [\App\Http\Controllers\CheckoutController::class, 'start'])
Route::get('/checkout', Checkout::class)->name('checkout');
Route::post('/checkout', [CheckoutController::class, 'start'])
// Throttled: each attempt opens a session on somebody else's API.
->middleware('throttle:10,1')
->name('checkout.start');
Route::get('/checkout/done', [\App\Http\Controllers\CheckoutController::class, 'done'])
Route::get('/checkout/done', [CheckoutController::class, 'done'])
->name('checkout.done');
// Return from an admin impersonation session (accessible as the
@ -439,7 +466,7 @@ if ($siteHost !== '') {
// runs in tests that dispatch through a router of their own, where
// the helper resolves whatever happens to be bound in the container
// rather than the request being answered.
->get('/{path?}', function (Illuminate\Http\Request $request, ?string $path = null) use ($siteHost) {
->get('/{path?}', function (Request $request, ?string $path = null) use ($siteHost) {
$target = 'https://'.$siteHost.'/'.ltrim((string) $path, '/');
$query = $request->getQueryString();

View File

@ -0,0 +1,151 @@
<?php
use App\Mail\ContactRequestMail;
use Illuminate\Support\Facades\Mail;
/**
* Das Anfrageformular der öffentlichen Website.
*
* Es steht offen im Internet und verschickt eine Mail die Grenzen sind hier
* das Wesentliche, nicht die Felder.
*/
beforeEach(function () {
Mail::fake();
});
it('zeigt das Formular', function () {
$this->get(route('contact'))
->assertOk()
->assertSee('Sprechen Sie uns an.')
->assertSee('Anfrage senden');
});
it('stellt beim Thema Enterprise die Frage, um die es geht', function () {
$this->get(route('contact', ['thema' => 'enterprise']))
->assertOk()
->assertSee('Eine eigene Maschine.')
// Nicht ein leeres Feld hinstellen: wer hier landet, kam über den Knopf
// unter den Paketen, und die zwei Zahlen entscheiden das Angebot.
->assertSee('Wie viel Speicher brauchen Sie', false);
});
it('fällt bei einem erfundenen Thema auf die allgemeine Fassung zurück', function () {
// ?thema= steht in der Adresse und ist damit von jedem frei wählbar.
$this->get(route('contact', ['thema' => '<script>']))
->assertOk()
->assertSee('Sprechen Sie uns an.');
});
it('schickt die Anfrage an das Haus und lässt die Antwort an den Fragenden gehen', function () {
$this->post(route('contact.send'), [
'company' => 'Kanzlei Muster',
'name' => 'Anna Muster',
'email' => 'anna@kanzlei-muster.at',
'phone' => '+43 1 2345678',
'message' => 'Wir haben 40 Mitarbeiter und brauchen etwa 400 GB.',
'topic' => 'enterprise',
])->assertRedirect(route('contact'));
Mail::assertQueued(ContactRequestMail::class, function (ContactRequestMail $mail) {
$envelope = $mail->envelope();
// Der Punkt der ganzen Mail: der Betreiber drückt „Antworten" und
// schreibt der Person, nicht sich selbst.
expect($envelope->replyTo[0]->address)->toBe('anna@kanzlei-muster.at');
// Gesendet wird AUS dem eigenen Haus, nicht aus der fremden Domain —
// sonst scheitert die Mail an SPF und DKIM und landet im Spam.
expect($envelope->from->address)->not->toBe('anna@kanzlei-muster.at');
// Betreff trägt Thema und Absender, damit die Postfachliste allein
// schon sagt, worum es geht.
expect($envelope->subject)->toBe('Anfrage: eigene Maschine — Kanzlei Muster');
return true;
});
});
it('nimmt den Namen in den Betreff, wenn keine Firma angegeben ist', function () {
$this->post(route('contact.send'), [
'name' => 'Bernd Beispiel',
'email' => 'bernd@example.at',
'message' => 'Ich hätte da eine Frage zu Ihren Paketen.',
]);
Mail::assertQueued(ContactRequestMail::class, fn (ContactRequestMail $mail) => $mail->envelope()->subject === 'Anfrage über die Website — Bernd Beispiel');
});
it('bestätigt sichtbar und zeigt das Formular danach nicht mehr', function () {
$this->post(route('contact.send'), [
'name' => 'Anna Muster',
'email' => 'anna@example.at',
'message' => 'Bitte melden Sie sich bei mir.',
]);
$this->followingRedirects()
->post(route('contact.send'), [
'name' => 'Anna Muster',
'email' => 'anna@example.at',
'message' => 'Bitte melden Sie sich bei mir.',
])
->assertOk()
->assertSee('Ihre Anfrage ist da.')
// Ein Formular, das nach dem Absenden weiter dasteht, lädt zum zweiten
// Absenden ein.
->assertDontSee('Anfrage senden');
});
it('verlangt Name, Adresse und Nachricht', function () {
$this->post(route('contact.send'), [])
->assertSessionHasErrors(['name', 'email', 'message']);
Mail::assertNothingQueued();
});
it('lässt eine Nachricht aus zwei Wörtern nicht durch', function () {
$this->post(route('contact.send'), [
'name' => 'Anna', 'email' => 'anna@example.at', 'message' => 'Hallo',
])->assertSessionHasErrors(['message']);
Mail::assertNothingQueued();
});
it('verwirft, was in den Honigtopf getappt ist', function () {
// Das Feld ist im Browser nicht zu sehen. Wer es ausfüllt, ist kein Mensch.
$this->post(route('contact.send'), [
'name' => 'Robot', 'email' => 'robot@example.com',
'message' => 'Kaufen Sie unsere Backlinks, sehr günstig.',
'website' => 'http://spam.example.com',
])->assertSessionHasErrors(['website']);
Mail::assertNothingQueued();
});
it('bremst den Versand, statt ihn offen stehen zu lassen', function () {
// Ein Formular, das ohne Bremse Mail verschickt, ist ein Versandweg für
// jeden, der es findet. Fünf in zehn Minuten, danach 429.
$payload = ['name' => 'Anna', 'email' => 'anna@example.at', 'message' => 'Eine ganz normale Anfrage.'];
for ($i = 0; $i < 5; $i++) {
$this->post(route('contact.send'), $payload)->assertRedirect();
}
$this->post(route('contact.send'), $payload)->assertStatus(429);
Mail::assertQueuedCount(5);
});
it('trägt fremdes Markup nicht in die Mail, die unsere Adresse als Absender hat', function () {
$this->post(route('contact.send'), [
'name' => 'Anna Muster',
'email' => 'anna@example.at',
'message' => "Guten Tag\n<script>alert('x')</script>\nMit freundlichen Grüßen",
]);
Mail::assertQueued(ContactRequestMail::class, function (ContactRequestMail $mail) {
$html = $mail->render();
return ! str_contains($html, "<script>alert('x')</script>")
&& str_contains($html, '&lt;script&gt;');
});
});