diff --git a/app/Http/Controllers/ContactController.php b/app/Http/Controllers/ContactController.php index 57995d9..de615f9 100644 --- a/app/Http/Controllers/ContactController.php +++ b/app/Http/Controllers/ContactController.php @@ -3,9 +3,12 @@ namespace App\Http\Controllers; use App\Mail\ContactRequestMail; +use App\Models\Customer; +use App\Models\InboundMail; use Illuminate\Http\RedirectResponse; use Illuminate\Http\Request; use Illuminate\Support\Facades\Mail; +use Illuminate\Support\Str; use Illuminate\View\View; /** @@ -58,14 +61,34 @@ class ContactController extends Controller 'message' => 'Nachricht', ]); - Mail::send(new ContactRequestMail([ + $enquiry = [ 'company' => $data['company'] ?? null, 'name' => $data['name'], 'email' => $data['email'], 'phone' => $data['phone'] ?? null, 'message' => $data['message'], 'topic' => $data['topic'] ?? 'general', - ])); + ]; + + // ZUERST festhalten, dann erst benachrichtigen. + // + // Die Mail war anfangs die einzige Ablage, und das ging in dem Moment + // schief, in dem der Versand scheiterte: der Besucher las „Ihre Anfrage + // ist da", und die Anfrage lag in `failed_jobs`, wo niemand nachsieht. + // Genau so ist es hier beim ersten Versuch passiert — das Postfach + // support@ hatte kein eigenes SMTP-Konto, und der Server wies den + // Absender ab. + // + // Der Posteingang der Konsole ist ohnehin die richtige Ablage: er + // existiert für „was Kunden uns geschrieben haben", zeigt Nicht- + // Zugeordnetes zuerst und nennt in seinem eigenen Kopfkommentar die + // neue Anfrage von einer unbekannten Adresse als den Fall, der nicht + // untergehen darf. Eine Website-Anfrage IST dieser Fall. + $this->record($enquiry); + + // Die Mail ist ab hier eine Benachrichtigung, kein Ablageort. Scheitert + // sie, steht die Anfrage trotzdem in der Konsole. + Mail::send(new ContactRequestMail($enquiry)); // Zurück auf dieselbe Seite, mit einer Fahne in der Sitzung statt einer // Kennung in der Adresse: ein Neuladen zeigt danach wieder das leere @@ -75,4 +98,47 @@ class ContactController extends Controller ->route('contact') ->with('contact.sent', true); } + + /** + * Die Anfrage in den Posteingang der Konsole legen. + * + * @param array{company: ?string, name: string, email: string, phone: ?string, message: string, topic: ?string} $enquiry + */ + private function record(array $enquiry): void + { + InboundMail::create([ + // Dieselbe Zuordnungsregel wie beim Abholen echter Mail + // (IngestInboundMail): schreibt ein bestehender Kunde über das + // Formular, hängt die Anfrage an ihm. Sonst bleibt sie ohne + // Zuordnung — und steht damit im Posteingang ganz oben. + 'customer_id' => Customer::query()->where('email', $enquiry['email'])->value('id'), + // Synthetisch, weil eine Formularanfrage keine echte Message-ID hat + // und die Spalte eindeutig sein muss. Das Präfix macht später + // erkennbar, dass dieser Eintrag nicht aus dem Postfach kam. + 'message_id' => 'web-'.Str::uuid().'@'.(parse_url((string) config('app.url'), PHP_URL_HOST) ?: 'clupilot'), + 'from_email' => $enquiry['email'], + 'from_name' => $enquiry['company'] + ? $enquiry['name'].' · '.$enquiry['company'] + : $enquiry['name'], + 'subject' => ContactRequestMail::subjectFor($enquiry), + 'body' => $this->bodyFor($enquiry), + 'received_at' => now(), + ]); + } + + /** + * Was der Posteingang als Nachricht zeigt. + * + * Die Telefonnummer gehört mit hinein: `inbound_mails` hat kein Feld dafür, + * und eine Nummer, die jemand extra angegeben hat, im Formular zu verlieren + * hieße, ihn nicht anrufen zu können. + * + * @param array{phone: ?string, message: string} $enquiry + */ + private function bodyFor(array $enquiry): string + { + return $enquiry['phone'] + ? $enquiry['message']."\n\n—\nTelefon: ".$enquiry['phone'] + : $enquiry['message']; + } } diff --git a/app/Mail/ContactRequestMail.php b/app/Mail/ContactRequestMail.php index 72a3ce5..af17457 100644 --- a/app/Mail/ContactRequestMail.php +++ b/app/Mail/ContactRequestMail.php @@ -65,15 +65,26 @@ class ContactRequestMail extends Mailable implements ShouldQueue ]); } + private function subjectLine(): string + { + return self::subjectFor($this->enquiry); + } + /** * Der Betreff trägt, wonach gefragt wird, und von wem — beides in der * Postfachliste sichtbar, ohne die Mail zu öffnen. + * + * Öffentlich und statisch, weil ihn zwei brauchen: diese Mail und der + * Eintrag im Posteingang der Konsole. Zwei Fassungen liefen auseinander, + * und dann hieße dieselbe Anfrage an zwei Stellen verschieden. + * + * @param array{company: ?string, name: string, topic: ?string} $enquiry */ - private function subjectLine(): string + public static function subjectFor(array $enquiry): string { - $who = $this->enquiry['company'] ?: $this->enquiry['name']; + $who = $enquiry['company'] ?: $enquiry['name']; - return $this->enquiry['topic'] === 'enterprise' + return ($enquiry['topic'] ?? null) === 'enterprise' ? 'Anfrage: eigene Maschine — '.$who : 'Anfrage über die Website — '.$who; } diff --git a/tests/Feature/ContactRequestTest.php b/tests/Feature/ContactRequestTest.php index 3e498d3..a73545a 100644 --- a/tests/Feature/ContactRequestTest.php +++ b/tests/Feature/ContactRequestTest.php @@ -1,6 +1,8 @@ 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', + ]); + + $eintrag = InboundMail::query()->latest('id')->first(); + + expect($eintrag)->not->toBeNull() + ->and($eintrag->from_email)->toBe('anna@kanzlei-muster.at') + ->and($eintrag->from_name)->toBe('Anna Muster · Kanzlei Muster') + ->and($eintrag->subject)->toBe('Anfrage: eigene Maschine — Kanzlei Muster') + ->and($eintrag->body)->toContain('40 Mitarbeiter') + // `inbound_mails` hat kein Feld für die Nummer. Sie im Formular zu + // verlieren hieße, den Fragenden nicht anrufen zu können. + ->and($eintrag->body)->toContain('+43 1 2345678') + // Eine fremde Adresse bleibt ohne Zuordnung — und steht damit im + // Posteingang ganz oben, wo eine neue Anfrage hingehört. + ->and($eintrag->customer_id)->toBeNull(); +}); + +it('hängt die Anfrage an den Kunden, wenn die Adresse schon bekannt ist', function () { + $kunde = Customer::factory()->create(['email' => 'bestand@example.at']); + + $this->post(route('contact.send'), [ + 'name' => 'Bestandskunde', + 'email' => 'bestand@example.at', + 'message' => 'Ich brauche mehr Speicher als mein Paket hergibt.', + ]); + + expect(InboundMail::query()->latest('id')->value('customer_id'))->toBe($kunde->id); +}); + +it('behält die Anfrage, auch wenn der Versand scheitert', function () { + // Der Fall, der das alles ausgelöst hat: support@ hatte kein eigenes + // SMTP-Konto, der Server wies den Absender ab, die Anfrage lag in + // `failed_jobs` — und der Besucher hatte „Ihre Anfrage ist da" gelesen. + Mail::shouldReceive('send')->once()->andThrow(new RuntimeException('553 Sender address rejected')); + + // Der Versand fliegt dem Besucher um die Ohren, statt ihm eine Bestätigung + // für etwas zu zeigen, das nicht angekommen ist. + $this->post(route('contact.send'), [ + 'name' => 'Anna Muster', + 'email' => 'anna@example.at', + 'message' => 'Bitte melden Sie sich bei mir.', + ])->assertStatus(500); + + // Festgehalten, bevor überhaupt ein Versand versucht wurde. + expect(InboundMail::query()->where('from_email', 'anna@example.at')->exists())->toBeTrue(); +}); + +it('gibt zwei Anfragen zwei verschiedene Kennungen', function () { + // message_id ist eindeutig. Eine zweite Anfrage darf nicht an der + // Datenbank scheitern, weil beide dieselbe synthetische Kennung tragen. + foreach (['eins@example.at', 'zwei@example.at'] as $adresse) { + $this->post(route('contact.send'), [ + 'name' => 'Anna', 'email' => $adresse, 'message' => 'Eine ganz normale Anfrage.', + ])->assertRedirect(); + } + + expect(InboundMail::query()->count())->toBe(2); +}); + it('trägt fremdes Markup nicht in die Mail, die unsere Adresse als Absender hat', function () { $this->post(route('contact.send'), [ 'name' => 'Anna Muster',