Anfrage zuerst in den Posteingang, dann erst die Mail: ein gescheiterter Versand darf sie nicht verschlucken

main
nexxo 2026-08-01 23:50:35 +02:00
parent e6ee0716f0
commit 9f6ddc317f
3 changed files with 151 additions and 5 deletions

View File

@ -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'];
}
}

View File

@ -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;
}

View File

@ -1,6 +1,8 @@
<?php
use App\Mail\ContactRequestMail;
use App\Models\Customer;
use App\Models\InboundMail;
use Illuminate\Support\Facades\Mail;
/**
@ -135,6 +137,73 @@ it('bremst den Versand, statt ihn offen stehen zu lassen', function () {
Mail::assertQueuedCount(5);
});
it('legt die Anfrage in den Posteingang der Konsole, nicht nur in eine Mail', 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',
]);
$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',