From 0e25fe88d4b400144985e2e514b3345fec98871a Mon Sep 17 00:00:00 2001 From: nexxo Date: Wed, 29 Jul 2026 21:02:36 +0200 Subject: [PATCH] Keep a register of what was sent, and answer the customer from here MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three complaints, one shape: the console knew things and could not act on them, so the work happened somewhere else and left no trace. The header said "4 Hinweis(e)" and led nowhere. Whoever read it had to know that the list was a card further down the same page, and then go looking for the host or the failed run by hand. The count is a link now, every notice carries the page that shows the thing it is about, and the plural is a word rather than a bracket. Nothing recorded what this installation had sent. "Hat der Kunde die Zugangsdaten je bekommen, und wann?" was answered on the mail server — a different machine, a different program, and nothing an operator can put in front of somebody who says nothing ever arrived. Every mail now writes a row: when, to whom, which mailable, and the customer it belongs to. Written from MessageSent, so a row means the transport accepted it; that is the strongest thing an application can honestly claim, and a column called "delivered" would be pretending otherwise. Verification and reset mails are left out on purpose — they go to unconfirmed addresses and anybody who can type into a form can send them, so recording them would hand the register to whoever wants to fill it. And the customer list was the end of the road. There is a page behind it now: what they bought, what they asked in their own words, what we sent them, and a box to write the next message. It goes out from the support mailbox and is recorded on the way — with its body, because an operator typed it and "what exactly did I write to them in March" is a question the mail server's log cannot answer either. Answering a request from here closes it, which is the reason to answer from here at all: answering from a mail client leaves it open for ever, because nothing tells it otherwise. Found on the way, by a test that sent a real mail rather than asserting it was queued: resources/views/mail/reset-password.blade.php was missing its closing . The component's contents were never terminated, the layout's own @if ran off the end of the file, and the mail raised a Blade syntax error instead of rendering. Password reset has therefore never delivered a link since it was built. Closed, plus a test that renders it and one that counts opening against closing tags across every mail view. Reading INCOMING mail in the console is not in this: it needs the mailbox polled over IMAP, which is a credential and a dependency. Requests filed through the portal appear here in full; a customer who writes by mail instead still has to be read in the mail client. Co-Authored-By: Claude Opus 5 --- VERSION | 2 +- app/Listeners/RecordSentMail.php | 89 ++++++++ app/Livewire/Admin/CustomerDetail.php | 143 ++++++++++++ app/Livewire/Admin/MailLog.php | 56 +++++ app/Livewire/Admin/Overview.php | 34 ++- app/Mail/OperatorMessageMail.php | 51 +++++ app/Models/Customer.php | 16 ++ app/Models/SentMail.php | 56 +++++ app/Providers/AppServiceProvider.php | 7 + app/Support/Navigation.php | 4 + ...record_every_mail_that_left_the_system.php | 63 ++++++ lang/de/admin.php | 3 +- lang/de/customer_detail.php | 21 ++ lang/de/customer_message.php | 20 ++ lang/de/mail_log.php | 16 ++ lang/en/admin.php | 3 +- lang/en/customer_detail.php | 21 ++ lang/en/customer_message.php | 20 ++ lang/en/mail_log.php | 16 ++ .../livewire/admin/customer-detail.blade.php | 146 +++++++++++++ .../views/livewire/admin/customers.blade.php | 7 +- .../views/livewire/admin/mail-log.blade.php | 58 +++++ .../views/livewire/admin/overview.blade.php | 35 ++- .../views/mail/operator-message.blade.php | 15 ++ resources/views/mail/reset-password.blade.php | 7 + routes/admin.php | 4 + tests/Feature/Admin/MailRegisterTest.php | 205 ++++++++++++++++++ tests/Feature/Auth/PasswordResetTest.php | 26 +++ 28 files changed, 1124 insertions(+), 20 deletions(-) create mode 100644 app/Listeners/RecordSentMail.php create mode 100644 app/Livewire/Admin/CustomerDetail.php create mode 100644 app/Livewire/Admin/MailLog.php create mode 100644 app/Mail/OperatorMessageMail.php create mode 100644 app/Models/SentMail.php create mode 100644 database/migrations/2026_07_30_100000_record_every_mail_that_left_the_system.php create mode 100644 lang/de/customer_detail.php create mode 100644 lang/de/customer_message.php create mode 100644 lang/de/mail_log.php create mode 100644 lang/en/customer_detail.php create mode 100644 lang/en/customer_message.php create mode 100644 lang/en/mail_log.php create mode 100644 resources/views/livewire/admin/customer-detail.blade.php create mode 100644 resources/views/livewire/admin/mail-log.blade.php create mode 100644 resources/views/mail/operator-message.blade.php create mode 100644 tests/Feature/Admin/MailRegisterTest.php diff --git a/VERSION b/VERSION index d2c582a..55a4dd6 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.3.32 +1.3.33 diff --git a/app/Listeners/RecordSentMail.php b/app/Listeners/RecordSentMail.php new file mode 100644 index 0000000..ce925cc --- /dev/null +++ b/app/Listeners/RecordSentMail.php @@ -0,0 +1,89 @@ +record($event); + } catch (Throwable $e) { + // A register that cannot be written must never be the reason a mail + // fails. The send already happened by the time this runs, and + // throwing here would turn a delivered message into a failed job + // that gets delivered a second time on retry. + Log::error('Could not record a sent mail', ['exception' => $e]); + } + } + + private function record(MessageSent $event): void + { + $mailable = $event->data['__laravel_mailable'] ?? null; + + if ($mailable !== null && in_array($mailable, self::IGNORED, true)) { + return; + } + + $message = $event->message; + $to = collect($message->getTo())->map(fn ($address) => $address->getAddress())->all(); + + if ($to === []) { + return; + } + + // One row per recipient. A mail to three addresses is three deliveries, + // and an operator asking "what did this customer get" must not have to + // read someone else's row to find out. + foreach ($to as $address) { + SentMail::create([ + 'customer_id' => $this->customerFor($address)?->id, + 'to' => $address, + 'subject' => (string) $message->getSubject(), + 'mailable' => $mailable, + // Set by the console when an operator writes one themselves; + // see Admin\CustomerMessage, which fills the body in afterwards. + 'from_operator' => false, + 'sent_at' => now(), + ]); + } + } + + /** Whose mail this was, where the address belongs to somebody we know. */ + private function customerFor(string $address): ?Customer + { + return Customer::query()->where('email', $address)->first(); + } +} diff --git a/app/Livewire/Admin/CustomerDetail.php b/app/Livewire/Admin/CustomerDetail.php new file mode 100644 index 0000000..e84883e --- /dev/null +++ b/app/Livewire/Admin/CustomerDetail.php @@ -0,0 +1,143 @@ +authorize('customers.manage'); + + $this->customer = Customer::query()->where('uuid', $uuid)->firstOrFail(); + } + + /** + * Start an answer to one request: subject prefilled, the request remembered. + */ + public function answer(string $uuid): void + { + $this->authorize('customers.manage'); + + $request = $this->customer->supportRequests()->where('uuid', $uuid)->first(); + + if ($request === null) { + return; + } + + $this->answering = $request->uuid; + $this->subject = __('customer_message.re', ['subject' => $request->subject]); + $this->body = ''; + } + + public function cancelAnswer(): void + { + $this->answering = null; + $this->subject = ''; + $this->body = ''; + } + + /** + * Send it, record it, and close the request it answers. + * + * The register row is written here rather than left to the MessageSent + * listener, because this is the one mail whose BODY is worth keeping: an + * operator typed it, and "what exactly did I write to them in March" is a + * question the mail server's log cannot answer either. + */ + public function send(): void + { + $this->authorize('customers.manage'); + + $data = $this->validate(); + + Mail::to($this->customer->email)->send( + new OperatorMessageMail($this->customer, $data['subject'], $data['body']) + ); + + // The listener has already written a row for the delivery — it fires on + // every mail this application sends. This adds what only the console + // knows: that a person wrote it, and what they wrote. + SentMail::query() + ->where('customer_id', $this->customer->id) + ->where('to', $this->customer->email) + ->latest('id') + ->limit(1) + ->update(['from_operator' => true, 'body' => $data['body']]); + + if ($this->answering !== null) { + $this->customer->supportRequests() + ->where('uuid', $this->answering) + ->update(['status' => 'answered', 'answered_at' => now()]); + } + + $this->cancelAnswer(); + $this->dispatch('notify', message: __('customer_message.sent')); + } + + public function render() + { + $this->authorize('customers.manage'); + + return view('livewire.admin.customer-detail', [ + 'requests' => $this->customer->supportRequests() + ->with('instance') + ->latest('id') + ->get(), + // The whole conversation as it left here, newest first. Capped: + // this is a page, not an export, and a customer of three years has + // a few hundred rows nobody scrolls. + 'mails' => SentMail::query() + ->where('customer_id', $this->customer->id) + ->latest('sent_at') + ->limit(50) + ->get(), + 'instance' => $this->customer->instances()->latest('id')->first(), + 'subscription' => $this->customer->subscriptions()->latest('id')->first(), + ]); + } +} diff --git a/app/Livewire/Admin/MailLog.php b/app/Livewire/Admin/MailLog.php new file mode 100644 index 0000000..645338b --- /dev/null +++ b/app/Livewire/Admin/MailLog.php @@ -0,0 +1,56 @@ +authorize('customers.manage'); + } + + public function updatedSearch(): void + { + $this->resetPage(); + } + + public function render() + { + $this->authorize('customers.manage'); + + $mails = SentMail::query() + ->with('customer') + ->when($this->search !== '', function ($q) { + $term = '%'.$this->search.'%'; + $q->where(fn ($w) => $w->where('to', 'like', $term)->orWhere('subject', 'like', $term)); + }) + ->latest('sent_at') + ->latest('id') + ->paginate(30); + + return view('livewire.admin.mail-log', ['mails' => $mails]); + } +} diff --git a/app/Livewire/Admin/Overview.php b/app/Livewire/Admin/Overview.php index fe5d3a5..7987e00 100644 --- a/app/Livewire/Admin/Overview.php +++ b/app/Livewire/Admin/Overview.php @@ -297,11 +297,23 @@ class Overview extends Component $failedRuns = ProvisioningRun::query()->where('status', ProvisioningRun::STATUS_FAILED)->count(); if ($failedRuns > 0) { - $notices[] = ['level' => 'warning', 'text' => __('admin.notice.failed_runs', ['n' => $failedRuns])]; + $notices[] = [ + 'level' => 'warning', + 'text' => __('admin.notice.failed_runs', ['n' => $failedRuns]), + // Every notice carries the page that shows the thing it is + // about. A warning an operator cannot act on from where they + // read it is a warning they go looking for by hand, and the + // count in the header was not even a link to the list. + 'route' => 'admin.provisioning', + ]; } foreach (Host::query()->where('status', 'error')->pluck('name') as $name) { - $notices[] = ['level' => 'warning', 'text' => __('admin.notice.host_error', ['host' => $name])]; + $notices[] = [ + 'level' => 'warning', + 'text' => __('admin.notice.host_error', ['host' => $name]), + 'route' => 'admin.hosts', + ]; } $silent = Host::query() @@ -311,9 +323,13 @@ class Overview extends Component ->orWhere('last_seen_at', '<', Carbon::now()->subMinutes(self::HOST_SILENT_AFTER_MINUTES))) ->pluck('name'); foreach ($silent as $name) { - $notices[] = ['level' => 'warning', 'text' => __('admin.notice.host_silent', [ - 'host' => $name, 'minutes' => self::HOST_SILENT_AFTER_MINUTES, - ])]; + $notices[] = [ + 'level' => 'warning', + 'text' => __('admin.notice.host_silent', [ + 'host' => $name, 'minutes' => self::HOST_SILENT_AFTER_MINUTES, + ]), + 'route' => 'admin.hosts', + ]; } // Distinct instances, not targets: one instance can be watched by @@ -326,7 +342,11 @@ class Overview extends Component ->distinct() ->count('instance_id'); if ($down > 0) { - $notices[] = ['level' => 'warning', 'text' => __('admin.notice.monitoring_down', ['n' => $down])]; + $notices[] = [ + 'level' => 'warning', + 'text' => __('admin.notice.monitoring_down', ['n' => $down]), + 'route' => 'admin.instances', + ]; } // Capacity, before an order finds out. @@ -340,7 +360,7 @@ class Overview extends Component // the decision; "buy a server big enough for the package I am selling" // is. foreach (app(HostCapacity::class)->unplaceablePlans() as $plan) { - $notices[] = ['level' => 'warning', 'text' => __('admin.notice.no_capacity', [ + $notices[] = ['level' => 'warning', 'route' => 'admin.capacity', 'text' => __('admin.notice.no_capacity', [ 'plan' => $plan['name'], 'needs' => $plan['needs'], 'largest' => $plan['largest'], diff --git a/app/Mail/OperatorMessageMail.php b/app/Mail/OperatorMessageMail.php new file mode 100644 index 0000000..b103038 --- /dev/null +++ b/app/Mail/OperatorMessageMail.php @@ -0,0 +1,51 @@ +mailer('cp_'.MailPurpose::SUPPORT); + } + + public function envelope(): Envelope + { + return $this->mailboxEnvelope(MailPurpose::SUPPORT, $this->subjectLine); + } + + public function content(): Content + { + return new Content(view: 'mail.operator-message', with: [ + 'name' => $this->customer->name, + 'bodyText' => $this->bodyText, + ]); + } +} diff --git a/app/Models/Customer.php b/app/Models/Customer.php index 5165bdf..2360b25 100644 --- a/app/Models/Customer.php +++ b/app/Models/Customer.php @@ -118,6 +118,22 @@ class Customer extends Model return $this->hasMany(Instance::class); } + public function subscriptions(): HasMany + { + return $this->hasMany(Subscription::class); + } + + public function supportRequests(): HasMany + { + return $this->hasMany(SupportRequest::class); + } + + /** Everything this installation has sent to them, newest first at the call site. */ + public function sentMails(): HasMany + { + return $this->hasMany(SentMail::class); + } + /** * True if this address already belongs to a customer identity — R21's * other direction, checked before an `operators` row is created or diff --git a/app/Models/SentMail.php b/app/Models/SentMail.php new file mode 100644 index 0000000..deef9f7 --- /dev/null +++ b/app/Models/SentMail.php @@ -0,0 +1,56 @@ + 'boolean', + 'sent_at' => 'datetime', + ]; + } + + public function customer(): BelongsTo + { + return $this->belongsTo(Customer::class); + } + + /** + * The mailable's short name, for a column that has to fit. + * + * "OrderConfirmationMail", not "App\Mail\OrderConfirmationMail" — the + * namespace is the same on every row and tells the reader nothing. + */ + public function kind(): string + { + return $this->mailable === null ? '—' : class_basename($this->mailable); + } +} diff --git a/app/Providers/AppServiceProvider.php b/app/Providers/AppServiceProvider.php index 802e947..f1ee8d1 100644 --- a/app/Providers/AppServiceProvider.php +++ b/app/Providers/AppServiceProvider.php @@ -6,6 +6,7 @@ use App\Http\Middleware\EnsureAdmin; use App\Http\Middleware\EnsureCustomerActive; use App\Http\Middleware\RestrictAdminHost; use App\Http\Middleware\RestrictConsoleNetwork; +use App\Listeners\RecordSentMail; use App\Listeners\RecordSignInDevice; use App\Mail\MaintenanceCancelledMail; use App\Mail\Transport\MailboxTransport; @@ -165,6 +166,12 @@ class AppServiceProvider extends ServiceProvider return null; }); + // The register of what this installation has sent, and to whom. One + // listener rather than a call in each mailable: a register that has to + // be remembered at every call site is a register with holes in it, and + // the holes are the mails nobody thought about. + Event::listen(MessageSent::class, RecordSentMail::class); + // Stamp a maintenance-notification ledger row as delivered only once the // mail is actually sent (the X-CP-Notification header carries the id). // Until then sent_at stays null → the row is a retryable marker. If an diff --git a/app/Support/Navigation.php b/app/Support/Navigation.php index fb8aa82..eada042 100644 --- a/app/Support/Navigation.php +++ b/app/Support/Navigation.php @@ -60,6 +60,10 @@ final class Navigation // site-visibility switch invites somebody to change one in passing. ['admin.finance', 'receipt', 'finance', 'site.manage'], ['admin.invoices', 'file-text', 'invoices', 'site.manage'], + // What this installation has sent, and to whom. Under Betrieb + // rather than System: it is read while answering a customer, + // not while configuring the machine. + ['admin.mail-log', 'send', 'mail_log', 'customers.manage'], ]], ['label' => __('admin.nav_group.system'), 'items' => [ ['admin.mail', 'mail', 'mail', 'mail.manage'], diff --git a/database/migrations/2026_07_30_100000_record_every_mail_that_left_the_system.php b/database/migrations/2026_07_30_100000_record_every_mail_that_left_the_system.php new file mode 100644 index 0000000..9eea417 --- /dev/null +++ b/database/migrations/2026_07_30_100000_record_every_mail_that_left_the_system.php @@ -0,0 +1,63 @@ +id(); + $table->uuid()->unique(); + + // Nullable, because not every mail has a customer behind it: a + // password reset can be triggered for an address that never bought + // anything, and an operator invitation is not a customer at all. + $table->foreignId('customer_id')->nullable()->constrained()->nullOnDelete(); + + $table->string('to'); + $table->string('subject'); + // The mailable class, so a filter can say "every invoice mail" + // without matching on a subject line that changes with the wording. + $table->string('mailable')->nullable(); + // Written by a person in the console rather than by the system. + $table->boolean('from_operator')->default(false); + $table->text('body')->nullable(); + + // Recorded on MessageSent — i.e. the transport accepted it. That is + // the strongest thing an application can honestly claim; what the + // receiving server did with it afterwards is not ours to know. + $table->timestamp('sent_at'); + $table->timestamps(); + + $table->index(['customer_id', 'sent_at']); + $table->index('sent_at'); + }); + } + + public function down(): void + { + Schema::dropIfExists('sent_mails'); + } +}; diff --git a/lang/de/admin.php b/lang/de/admin.php index dc84ddd..9d9284f 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -11,6 +11,7 @@ return [ ], 'nav' => [ + 'mail_log' => 'Postausgang', 'overview' => 'Übersicht', 'customers' => 'Kunden', 'instances' => 'Instanzen', @@ -36,7 +37,7 @@ return [ 'overview_title' => 'Fleet-Übersicht', 'overview_sub' => 'Zustand der gesamten Plattform auf einen Blick.', 'systems_ok' => 'Nichts gemeldet', - 'systems_notices' => ':n Hinweis(e)', + 'systems_notices' => '{1} :n Hinweis|[2,*] :n Hinweise', 'kpi' => [ 'customers' => 'Kunden', diff --git a/lang/de/customer_detail.php b/lang/de/customer_detail.php new file mode 100644 index 0000000..d1fe5db --- /dev/null +++ b/lang/de/customer_detail.php @@ -0,0 +1,21 @@ + 'Alle Kunden', + 'contract' => 'Vertrag', + 'plan' => 'Paket', + 'instance' => 'Instanz', + 'since' => 'Kunde seit', + 'vat_id' => 'UID', + 'write_invoice' => 'Rechnung schreiben', + + 'requests' => 'Anfragen aus dem Kundenbereich', + 'no_requests' => 'Bisher keine Anfrage.', + 'answer' => 'Antworten', + + 'mails' => 'Gesendete E-Mails', + 'no_mails' => 'An diesen Kunden ging bisher keine E-Mail.', + 'all_mails' => 'Alle E-Mails', + 'by_hand' => 'Von Hand', +]; diff --git a/lang/de/customer_message.php b/lang/de/customer_message.php new file mode 100644 index 0000000..1790671 --- /dev/null +++ b/lang/de/customer_message.php @@ -0,0 +1,20 @@ + 'Nachricht schreiben', + 'subject' => 'Betreff', + 'body' => 'Nachricht', + 'body_hint' => 'Was Sie dem Kunden antworten möchten.', + 'send' => 'Senden', + 'note' => 'Geht an :email und wird im Postausgang festgehalten.', + 'sent' => 'Nachricht gesendet und protokolliert.', + + 'answering' => 'Antwort auf eine Anfrage', + 'cancel_answer' => 'Bezug aufheben', + 're' => 'Re: :subject', + + 'mail_heading' => 'Nachricht von CluPilot', + 'mail_greeting' => 'Guten Tag :name,', +]; diff --git a/lang/de/mail_log.php b/lang/de/mail_log.php new file mode 100644 index 0000000..b3c0e33 --- /dev/null +++ b/lang/de/mail_log.php @@ -0,0 +1,16 @@ + 'Postausgang', + 'subtitle' => 'Jede E-Mail, die diese Installation verschickt hat — wann, an wen, mit welchem Betreff. Eine Zeile bedeutet: der Mailserver hat sie angenommen.', + 'search' => 'Suchen', + 'search_hint' => 'Adresse oder Betreff', + 'empty' => 'Es wurde noch nichts verschickt.', + 'col_when' => 'Wann', + 'col_to' => 'An', + 'col_subject' => 'Betreff', + 'col_kind' => 'Art', + 'by_hand' => 'Von Hand', +]; diff --git a/lang/en/admin.php b/lang/en/admin.php index 2aa8383..058c2eb 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -11,6 +11,7 @@ return [ ], 'nav' => [ + 'mail_log' => 'Mail sent', 'overview' => 'Overview', 'customers' => 'Customers', 'instances' => 'Instances', @@ -36,7 +37,7 @@ return [ 'overview_title' => 'Fleet overview', 'overview_sub' => 'The whole platform at a glance.', 'systems_ok' => 'Nothing reported', - 'systems_notices' => ':n notice(s)', + 'systems_notices' => '{1} :n notice|[2,*] :n notices', 'kpi' => [ 'customers' => 'Customers', diff --git a/lang/en/customer_detail.php b/lang/en/customer_detail.php new file mode 100644 index 0000000..adaee7f --- /dev/null +++ b/lang/en/customer_detail.php @@ -0,0 +1,21 @@ + 'All customers', + 'contract' => 'Contract', + 'plan' => 'Package', + 'instance' => 'Instance', + 'since' => 'Customer since', + 'vat_id' => 'VAT ID', + 'write_invoice' => 'Write an invoice', + + 'requests' => 'Requests from the portal', + 'no_requests' => 'No request so far.', + 'answer' => 'Answer', + + 'mails' => 'Mail sent', + 'no_mails' => 'Nothing has been sent to this customer yet.', + 'all_mails' => 'All mail', + 'by_hand' => 'By hand', +]; diff --git a/lang/en/customer_message.php b/lang/en/customer_message.php new file mode 100644 index 0000000..9bfd448 --- /dev/null +++ b/lang/en/customer_message.php @@ -0,0 +1,20 @@ + 'Write a message', + 'subject' => 'Subject', + 'body' => 'Message', + 'body_hint' => 'What you would like to tell the customer.', + 'send' => 'Send', + 'note' => 'Goes to :email and is recorded in the mail register.', + 'sent' => 'Message sent and recorded.', + + 'answering' => 'Answering a request', + 'cancel_answer' => 'Detach', + 're' => 'Re: :subject', + + 'mail_heading' => 'A message from CluPilot', + 'mail_greeting' => 'Hello :name,', +]; diff --git a/lang/en/mail_log.php b/lang/en/mail_log.php new file mode 100644 index 0000000..afc4868 --- /dev/null +++ b/lang/en/mail_log.php @@ -0,0 +1,16 @@ + 'Mail sent', + 'subtitle' => 'Every mail this installation has sent — when, to whom, with what subject. A row means the mail server accepted it.', + 'search' => 'Search', + 'search_hint' => 'Address or subject', + 'empty' => 'Nothing has been sent yet.', + 'col_when' => 'When', + 'col_to' => 'To', + 'col_subject' => 'Subject', + 'col_kind' => 'Kind', + 'by_hand' => 'By hand', +]; diff --git a/resources/views/livewire/admin/customer-detail.blade.php b/resources/views/livewire/admin/customer-detail.blade.php new file mode 100644 index 0000000..26ec441 --- /dev/null +++ b/resources/views/livewire/admin/customer-detail.blade.php @@ -0,0 +1,146 @@ +
+
+
+ + ‹ {{ __('customer_detail.back') }} + +

{{ $customer->name }}

+

{{ $customer->email }}

+
+ + {{ __('customers.status.'.$customer->status) }} + +
+ +
+
+ {{-- ── Write to them ──────────────────────────────────────────── + The point of the page. An operator answering a question used to + have the name in one window and a mail client in another, and + afterwards nothing in the console said an answer had ever been + given. --}} +
+
+

{{ __('customer_message.title') }}

+ @if ($answering) + {{ __('customer_message.answering') }} + + @endif +
+ +
+ +
+ + + @error('body')

{{ $message }}

@enderror +
+
+ + {{ __('customer_message.send') }} + +

{{ __('customer_message.note', ['email' => $customer->email]) }}

+
+ +
+ + {{-- ── What they asked ──────────────────────────────────────── --}} +
+

{{ __('customer_detail.requests') }}

+ + @if ($requests->isEmpty()) +

{{ __('customer_detail.no_requests') }}

+ @else +
    + @foreach ($requests as $request) +
  • +
    + {{ $request->subject }} + + {{ __('support.status.'.$request->status) }} + + {{ $request->created_at->local()->isoFormat('D. MMM YYYY, HH:mm') }} +
    + {{-- The customer's own words, whole. Reading the + question was the other reason to leave the + console. --}} +

    {{ $request->body }}

    + @if ($request->isOpen()) + + {{ __('customer_detail.answer') }} + + @endif +
  • + @endforeach +
+ @endif +
+ + {{-- ── What we sent ───────────────────────────────────────────── + The delivery register, filtered to this customer: when, which + mail, and — where an operator wrote it — what it said. --}} +
+
+

{{ __('customer_detail.mails') }}

+ + {{ __('customer_detail.all_mails') }} › + +
+ + @if ($mails->isEmpty()) +

{{ __('customer_detail.no_mails') }}

+ @else +
    + @foreach ($mails as $mail) +
  • +
    + {{ $mail->subject }} + @if ($mail->from_operator) + {{ __('customer_detail.by_hand') }} + @endif + + {{ $mail->sent_at->local()->isoFormat('D. MMM YYYY, HH:mm') }} + +
    + @if ($mail->body) +

    {{ $mail->body }}

    + @else +

    {{ $mail->kind() }}

    + @endif +
  • + @endforeach +
+ @endif +
+
+ + {{-- ── Who they are ─────────────────────────────────────────────── --}} +
+

{{ __('customer_detail.contract') }}

+ +
+ @foreach ([ + __('customer_detail.plan') => $subscription ? __('billing.plan.'.$subscription->plan) : '—', + __('customer_detail.instance') => $instance?->subdomain ?? '—', + __('customer_detail.since') => $customer->created_at->local()->isoFormat('D. MMM YYYY'), + __('customer_detail.vat_id') => $customer->vat_id ?: '—', + ] as $term => $value) +
+
{{ $term }}
+
{{ $value }}
+
+ @endforeach +
+ + {{-- Prefilled with this customer, so writing an invoice for the work + just agreed is one click from the conversation about it. --}} + + {{ __('customer_detail.write_invoice') }} + +
+
+
diff --git a/resources/views/livewire/admin/customers.blade.php b/resources/views/livewire/admin/customers.blade.php index 9dc80b1..5d3b6df 100644 --- a/resources/views/livewire/admin/customers.blade.php +++ b/resources/views/livewire/admin/customers.blade.php @@ -21,7 +21,12 @@ @forelse ($rows as $r) -

{{ $r['name'] }}

+ {{-- The row leads somewhere now. The list + was the end of the road: an operator + answering a question had the name here + and the mail client in another window. --}} + {{ $r['name'] }}

{{ $r['instance'] }}.clupilot.com

diff --git a/resources/views/livewire/admin/mail-log.blade.php b/resources/views/livewire/admin/mail-log.blade.php new file mode 100644 index 0000000..6661c09 --- /dev/null +++ b/resources/views/livewire/admin/mail-log.blade.php @@ -0,0 +1,58 @@ +
+
+

{{ __('mail_log.title') }}

+

{{ __('mail_log.subtitle') }}

+
+ +
+
+
+ +
+
+ + @if ($mails->isEmpty()) +

{{ __('mail_log.empty') }}

+ @else + + + + + + + + + + + @foreach ($mails as $mail) + + {{-- R19: stored in UTC, read on the wall clock. --}} + + + + + + @endforeach + +
{{ __('mail_log.col_when') }}{{ __('mail_log.col_to') }}{{ __('mail_log.col_subject') }}{{ __('mail_log.col_kind') }}
+ {{ $mail->sent_at->local()->isoFormat('D. MMM YYYY, HH:mm:ss') }} + + @if ($mail->customer) + {{ $mail->customer->name }} +

{{ $mail->to }}

+ @else + {{ $mail->to }} + @endif +
+ {{ $mail->subject }} + @if ($mail->from_operator) + {{ __('mail_log.by_hand') }} + @endif + {{ $mail->kind() }}
+ +
{{ $mails->links() }}
+ @endif +
+
diff --git a/resources/views/livewire/admin/overview.blade.php b/resources/views/livewire/admin/overview.blade.php index 3fffb83..1e7b259 100644 --- a/resources/views/livewire/admin/overview.blade.php +++ b/resources/views/livewire/admin/overview.blade.php @@ -8,9 +8,13 @@ {{ __('admin.systems_ok') }} @else - - {{ __('admin.systems_notices', ['n' => $noticeCount]) }} - + {{-- A link, not a label. It said "4 Hinweis(e)" and led nowhere: + whoever read it had to know that the list was a card further + down the same page, and the plural was a bracket rather than a + word. --}} + + {{ trans_choice('admin.systems_notices', $noticeCount, ['n' => $noticeCount]) }} + @endif

{{ __('admin.overview_sub') }}

@@ -118,14 +122,27 @@ - {{-- Notices --}} -
+ {{-- Notices. Each one links to the page that shows the thing it is + about — a warning somebody has to go and find by hand is a warning + that waits until the next time they happen to look. --}} +

{{ __('admin.alerts') }}

-
    +
      @forelse ($notices as $a) -
    • - - {{ $a['text'] }} +
    • + @if ($a['route'] ?? null) + + + {{ $a['text'] }} + + + @else +
      + + {{ $a['text'] }} +
      + @endif
    • @empty
    • {{ __('admin.no_notices') }}
    • diff --git a/resources/views/mail/operator-message.blade.php b/resources/views/mail/operator-message.blade.php new file mode 100644 index 0000000..c3b310a --- /dev/null +++ b/resources/views/mail/operator-message.blade.php @@ -0,0 +1,15 @@ + + + + {{-- The operator's own words, as they typed them. nl2br over an escaped + string, never {!! !!}: this text comes out of a form, and a message + somebody pastes in must not be able to bring markup into a mail we + sign our name to. --}} +

      {!! nl2br(e($bodyText)) !!}

      + + +
      diff --git a/resources/views/mail/reset-password.blade.php b/resources/views/mail/reset-password.blade.php index f12437f..8293742 100644 --- a/resources/views/mail/reset-password.blade.php +++ b/resources/views/mail/reset-password.blade.php @@ -37,3 +37,10 @@

      {{ __('reset_password.not_you') }}

      + +{{-- The closing tag. Without it the component's contents were never + terminated: the layout's own @if ran off the end of the file and the mail + raised a Blade syntax error instead of rendering — so the reset link never + reached anybody who asked for one. Every other mail view in this directory + closes; this was the only one that did not. --}} + diff --git a/routes/admin.php b/routes/admin.php index 186931c..f88c541 100644 --- a/routes/admin.php +++ b/routes/admin.php @@ -41,6 +41,10 @@ Route::get('/capacity', Admin\Capacity::class)->name('capacity'); Route::get('/vpn', Admin\Vpn::class)->name('vpn'); Route::get('/revenue', Admin\Revenue::class)->name('revenue'); Route::get('/finance', Admin\Finance::class)->name('finance'); +// One customer, everything about them, and a box to write to them. +Route::get('/customers/{uuid}', Admin\CustomerDetail::class)->name('customer'); +// The register of what left the building — see Admin\MailLog. +Route::get('/mail-log', Admin\MailLog::class)->name('mail-log'); Route::get('/invoices', Admin\Invoices::class)->name('invoices'); // Writing one by hand, for work that came off no price list. Same series, same // numbering — see Admin\NewInvoice. diff --git a/tests/Feature/Admin/MailRegisterTest.php b/tests/Feature/Admin/MailRegisterTest.php new file mode 100644 index 0000000..d0eaf67 --- /dev/null +++ b/tests/Feature/Admin/MailRegisterTest.php @@ -0,0 +1,205 @@ +create(); + + Mail::to($customer->email)->send(new OperatorMessageMail($customer, 'Betreff', 'Text')); + + $row = SentMail::query()->latest('id')->first(); + + expect($row)->not->toBeNull() + ->and($row->to)->toBe($customer->email) + ->and($row->subject)->toBe('Betreff') + ->and($row->mailable)->toBe(OperatorMessageMail::class) + // Attached to the customer, so the question "what did THIS customer + // get" does not mean reading the whole register. + ->and($row->customer_id)->toBe($customer->id); +}); + +it('records the send, not the intention', function () { + // On MessageSent, not MessageSending: a row here has to mean the transport + // accepted it. Recording intentions would fill the register with mails that + // never left — worse than no register, because an operator would show a + // customer a line proving something was sent that was not. + expect(config('mail.default'))->not->toBeNull(); + + $before = SentMail::query()->count(); + Mail::fake(); + + $customer = Customer::factory()->create(); + Mail::to($customer->email)->send(new OperatorMessageMail($customer, 'Betreff', 'Text')); + + // Mail::fake() never hands the message to a transport, so nothing was sent + // and nothing is claimed. + expect(SentMail::query()->count())->toBe($before); +}); + +it('keeps a mail to an address nobody owns, without inventing a customer for it', function () { + Mail::to('fremder@example.test')->send( + new OperatorMessageMail(Customer::factory()->create(), 'Betreff', 'Text') + ); + + $row = SentMail::query()->where('to', 'fremder@example.test')->first(); + + expect($row)->not->toBeNull() + ->and($row->customer_id)->toBeNull(); +}); + +it('does not let a stranger write rows by asking for a password reset', function () { + // The verification and reset mails go to addresses nobody has confirmed, + // and anyone who can type into a form can send them. Recording them would + // hand the operator's register to whoever wants to fill it. + $user = App\Models\User::factory()->create(); + + Mail::to($user->email)->send(new App\Mail\ResetPasswordMail($user, 'https://example.test/reset', 60)); + + expect(SentMail::query()->where('to', $user->email)->exists())->toBeFalse(); +}); + +it('shows the register, newest first, with the customer it belongs to', function () { + $customer = Customer::factory()->create(['name' => 'Kanzlei Berger']); + + SentMail::create([ + 'customer_id' => $customer->id, + 'to' => $customer->email, + 'subject' => 'Ihre Zugangsdaten', + 'mailable' => App\Mail\OperatorMessageMail::class, + 'sent_at' => now()->subHour(), + ]); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(MailLog::class) + ->assertOk() + ->assertSee('Ihre Zugangsdaten') + ->assertSee('Kanzlei Berger'); +}); + +// ---- Writing to a customer from the console ---- + +it('sends the operator message and records what was written', function () { + // The body is kept only for a mail a person typed: "what exactly did I + // write to them in March" is a question the mail server's log cannot + // answer either. + $customer = Customer::factory()->create(); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(CustomerDetail::class, ['uuid' => $customer->uuid]) + ->set('subject', 'Zur Datenübernahme') + ->set('body', 'Wir haben uns Ihr System angesehen und können übernehmen.') + ->call('send'); + + $row = SentMail::query()->where('customer_id', $customer->id)->latest('id')->first(); + + expect($row)->not->toBeNull() + ->and($row->subject)->toBe('Zur Datenübernahme') + ->and($row->from_operator)->toBeTrue() + ->and($row->body)->toBe('Wir haben uns Ihr System angesehen und können übernehmen.'); +}); + +it('closes the request it answers, which is the reason to answer from here', function () { + // Answering from a mail client leaves the request open for ever, because + // nothing ever told it otherwise. + $customer = Customer::factory()->create(); + $request = SupportRequest::create([ + 'customer_id' => $customer->id, + 'subject' => 'Können Sie unsere Daten übernehmen?', + 'category' => 'other', + 'body' => 'Wir haben ownCloud 10 im Haus.', + 'status' => 'open', + 'reported_by' => $customer->email, + ]); + + $page = Livewire::actingAs(operator('Owner'), 'operator') + ->test(CustomerDetail::class, ['uuid' => $customer->uuid]) + ->call('answer', $request->uuid); + + expect($page->get('subject'))->toContain('Können Sie unsere Daten übernehmen?'); + + $page->set('body', 'Ja, das geht — Angebot folgt.')->call('send'); + + expect($request->fresh()->status)->toBe('answered') + ->and($request->fresh()->answered_at)->not->toBeNull(); +}); + +it('shows the customer their own words, so nobody opens the mail client to read the question', function () { + $customer = Customer::factory()->create(); + SupportRequest::create([ + 'customer_id' => $customer->id, + 'subject' => 'Frage zur Migration', + 'category' => 'other', + 'body' => 'Wir haben 42 GB in ownCloud und wollen wechseln.', + 'status' => 'open', + 'reported_by' => $customer->email, + ]); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(CustomerDetail::class, ['uuid' => $customer->uuid]) + ->assertSee('Wir haben 42 GB in ownCloud und wollen wechseln.'); +}); + +it('refuses an empty message rather than sending one', function () { + $customer = Customer::factory()->create(); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(CustomerDetail::class, ['uuid' => $customer->uuid]) + ->call('send') + ->assertHasErrors(['subject', 'body']); + + expect(SentMail::query()->where('customer_id', $customer->id)->exists())->toBeFalse(); +}); + +it('keeps both pages to operators who may see customers', function () { + // Support KEEPS both, deliberately: answering customers is the job, and a + // support operator who cannot read what was already sent them is answering + // blind. Read-only is the role that may look at the console and change + // nothing — writing a mail is a change, and so is reading the register of + // what was written. + $customer = Customer::factory()->create(); + + expect(operator('Support')->can('customers.manage'))->toBeTrue(); + + Livewire::actingAs(operator('Read-only'), 'operator') + ->test(CustomerDetail::class, ['uuid' => $customer->uuid]) + ->assertForbidden(); + + Livewire::actingAs(operator('Read-only'), 'operator') + ->test(MailLog::class) + ->assertForbidden(); +}); + +// ---- The notices somebody can finally act on ---- + +it('gives every notice the page that shows the thing it is about', function () { + // The count in the header said "4 Hinweis(e)" and led nowhere: whoever read + // it had to know the list was a card further down the same page, and then + // go looking for the host or the run by hand. + App\Models\Host::factory()->create(['status' => 'error', 'name' => 'pve-broken']); + + Livewire::actingAs(operator('Owner'), 'operator') + ->test(Overview::class) + ->assertViewHas('notices', fn (array $notices) => collect($notices) + ->every(fn (array $n) => array_key_exists('route', $n) && $n['route'] !== null)) + ->assertSee(route('admin.hosts'), false); +}); + +it('counts notices in words, not in brackets', function () { + expect(trans_choice('admin.systems_notices', 1, ['n' => 1]))->toBe('1 Hinweis') + ->and(trans_choice('admin.systems_notices', 4, ['n' => 4]))->toBe('4 Hinweise'); +}); diff --git a/tests/Feature/Auth/PasswordResetTest.php b/tests/Feature/Auth/PasswordResetTest.php index e09670e..cf346c0 100644 --- a/tests/Feature/Auth/PasswordResetTest.php +++ b/tests/Feature/Auth/PasswordResetTest.php @@ -129,3 +129,29 @@ it('insists on a password long enough to be worth resetting to', function () { ->call('save') ->assertHasErrors('password'); }); + +it('renders the reset mail, which it could not do at all', function () { + // The view was missing its closing . The component's + // contents were never terminated, the layout's own @if ran off the end of + // the file, and the mail raised a Blade syntax error instead of rendering — + // so the link never reached anybody who asked for one. Every test until now + // asserted that the mail was QUEUED, which it faithfully was. + $user = App\Models\User::factory()->create(['name' => 'Bea Berger']); + + $html = (new App\Mail\ResetPasswordMail($user, 'https://example.test/reset/abc', 60)) + ->render(); + + expect($html)->toContain('https://example.test/reset/abc') + ->toContain(__('reset_password.action')); +}); + +it('closes every mail layout it opens', function () { + // One unterminated component is a mail that cannot be sent, and the failure + // only shows at render time — long after the test that queued it went green. + foreach (glob(resource_path('views/mail/*.blade.php')) as $file) { + $body = file_get_contents($file); + + expect(substr_count($body, 'toBe(substr_count($body, ''), basename($file)); + } +});