diff --git a/app/Actions/StartCustomerProvisioning.php b/app/Actions/StartCustomerProvisioning.php index 89e06a9..dfb73ba 100644 --- a/app/Actions/StartCustomerProvisioning.php +++ b/app/Actions/StartCustomerProvisioning.php @@ -2,6 +2,7 @@ namespace App\Actions; +use App\Mail\OrderConfirmationMail; use App\Exceptions\IdentityCollisionException; use App\Models\Customer; use App\Models\Order; @@ -12,6 +13,7 @@ use App\Services\Billing\PlanCatalogue; use Illuminate\Database\UniqueConstraintViolationException; use Illuminate\Support\Facades\DB; use Illuminate\Support\Facades\Log; +use Illuminate\Support\Facades\Mail; use Throwable; /** @@ -106,9 +108,45 @@ class StartCustomerProvisioning AdvanceRunJob::dispatch($run->uuid); // after commit + $this->confirmByMail($order, $customer); + return $order; } + /** + * Tell the customer the payment landed, before anything is running. + * + * Sent here rather than when provisioning finishes: those are minutes apart + * at best and can be much longer, and somebody who has just been charged + * and heard nothing assumes the worst about both the charge and the + * product. CloudReady announces the end; this announces the beginning. + * + * After the transaction, never inside it: a queued job can be picked up by + * a worker before the commit lands, and it would then read an order that + * does not exist yet. + * + * Never allowed to fail the webhook. Stripe retries anything that is not a + * 2xx, and a retry here would re-enter provisioning over a mail server + * having a bad minute. + */ + private function confirmByMail(Order $order, Customer $customer): void + { + $address = $customer->email; + + if (! is_string($address) || $address === '') { + return; + } + + try { + Mail::to($address)->queue(new OrderConfirmationMail($order, (string) $customer->name)); + } catch (Throwable $e) { + Log::warning('Could not queue the order confirmation', [ + 'order' => $order->id, + 'exception' => $e->getMessage(), + ]); + } + } + /** * Finish what a crash interrupted. * diff --git a/app/Mail/MaintenanceAnnouncementMail.php b/app/Mail/MaintenanceAnnouncementMail.php index 5a69eda..13d2bdf 100644 --- a/app/Mail/MaintenanceAnnouncementMail.php +++ b/app/Mail/MaintenanceAnnouncementMail.php @@ -45,7 +45,7 @@ class MaintenanceAnnouncementMail extends Mailable implements ShouldQueue public function content(): Content { - return new Content(markdown: 'mail.maintenance-announcement', with: [ + return new Content(view: 'mail.maintenance-announcement', with: [ 'title' => $this->window->title, 'description' => $this->window->public_description, 'startsAt' => $this->window->starts_at, diff --git a/app/Mail/MaintenanceCancelledMail.php b/app/Mail/MaintenanceCancelledMail.php index e5a43a6..f0ba4b3 100644 --- a/app/Mail/MaintenanceCancelledMail.php +++ b/app/Mail/MaintenanceCancelledMail.php @@ -43,7 +43,7 @@ class MaintenanceCancelledMail extends Mailable implements ShouldQueue public function content(): Content { - return new Content(markdown: 'mail.maintenance-cancelled', with: [ + return new Content(view: 'mail.maintenance-cancelled', with: [ 'title' => $this->window->title, 'name' => $this->customer->name, ]); diff --git a/app/Mail/OrderConfirmationMail.php b/app/Mail/OrderConfirmationMail.php new file mode 100644 index 0000000..ca0009b --- /dev/null +++ b/app/Mail/OrderConfirmationMail.php @@ -0,0 +1,78 @@ +mailboxEnvelope( + MailPurpose::BILLING, + __('orders.mail_subject', ['number' => $this->order->uuid ?? $this->order->id]), + ); + } + + public function content(): Content + { + return new Content(view: 'mail.order-confirmation', with: [ + 'name' => $this->name, + 'order' => $this->order, + // Through the model, so the cart, the invoice list and this mail + // cannot each invent their own wording for the same row. + 'label' => $this->order->label(), + 'amount' => $this->amount(), + 'location' => $this->location(), + 'invoicesUrl' => route('invoices'), + ]); + } + + /** The charge, formatted where the currency is known rather than in a view. */ + private function amount(): string + { + return number_format($this->order->amount_cents / 100, 2, ',', '.') + .' '.strtoupper((string) $this->order->currency); + } + + private function location(): string + { + $datacenter = \App\Models\Datacenter::query() + ->where('code', $this->order->datacenter) + ->first(); + + if ($datacenter === null) { + return (string) $this->order->datacenter; + } + + return $datacenter->facility + ? $datacenter->name.' · '.$datacenter->facility + : $datacenter->name; + } +} diff --git a/app/Notifications/CloudReady.php b/app/Notifications/CloudReady.php index 1c5fa2c..cddf63f 100644 --- a/app/Notifications/CloudReady.php +++ b/app/Notifications/CloudReady.php @@ -56,12 +56,48 @@ class CloudReady extends Notification $message->replyTo($replyTo->address, $replyTo->name); } + // ->view() rather than the notification's markdown builder: the default + // arrives with the framework's logo, button and footer, and this is the + // mail that greets somebody on the day their product starts working. + // Kept as a Notification rather than turned into a Mailable, because + // CompleteProvisioning depends on it being sent synchronously and on + // what happens after the send returns. return $message ->subject(__('provisioning.mail.ready_subject')) - ->greeting(__('provisioning.mail.ready_greeting')) - ->line(__('provisioning.mail.ready_line')) - ->line(__('provisioning.mail.ready_address', ['url' => $url])) - ->line(__('provisioning.mail.ready_credentials')) - ->action(__('provisioning.mail.ready_action'), route('dashboard')); + ->view('mail.cloud-ready', [ + 'name' => (string) ($notifiable->name ?? ''), + 'url' => $url, + 'plan' => $this->instance->plan, + 'storage' => __('provisioning.mail.value_storage', ['gb' => $this->instance->quota_gb]), + 'location' => $this->location(), + 'dashboardUrl' => route('dashboard'), + ]); + } + + /** + * The datacenter in words, falling back to the code. + * + * Read through the host, because an instance knows which machine it is on + * and a machine knows where it stands. A missing name is not worth a second + * query or an empty row — the code is what the customer chose at checkout + * and will recognise. + */ + private function location(): string + { + $code = $this->instance->host?->datacenter; + + if (! is_string($code) || $code === '') { + return '—'; + } + + $datacenter = \App\Models\Datacenter::query()->where('code', $code)->first(); + + if ($datacenter === null) { + return $code; + } + + return $datacenter->facility + ? $datacenter->name.' · '.$datacenter->facility + : $datacenter->name; } } diff --git a/lang/de/maintenance.php b/lang/de/maintenance.php index 31e34a0..be3a046 100644 --- a/lang/de/maintenance.php +++ b/lang/de/maintenance.php @@ -58,6 +58,8 @@ return [ 'mail_heading' => 'Geplante Wartung', 'mail_greeting' => 'Hallo :name,', 'mail_window' => 'Zeitraum: :start bis :end.', + 'mail_field_start' => 'Beginn', + 'mail_field_end' => 'Ende', 'mail_no_action' => 'Es ist nichts zu tun. Ihre Daten bleiben sicher.', 'mail_signoff' => 'Viele Grüße', diff --git a/lang/de/orders.php b/lang/de/orders.php new file mode 100644 index 0000000..f8d98e5 --- /dev/null +++ b/lang/de/orders.php @@ -0,0 +1,18 @@ + 'Ihre Bestellung :number', + 'mail_heading' => 'Bestellung bestätigt', + 'mail_preheader' => ':label — Zahlung eingegangen.', + 'mail_greeting' => 'Guten Tag :name,', + 'mail_intro' => 'vielen Dank für Ihre Bestellung. Die Zahlung ist eingegangen, und wir richten Ihre Umgebung ein.', + 'field_product' => 'Produkt', + 'field_location' => 'Standort', + 'field_number' => 'Bestellnummer', + 'field_amount' => 'Betrag', + // Bewusst ohne Zeitangabe: die Einrichtung dauert, was sie dauert, und + // eine zugesagte Minute, die verstreicht, ist schlimmer als keine Zusage. + 'mail_next' => 'Sobald alles läuft, bekommen Sie eine zweite Nachricht mit der Adresse Ihrer Cloud. Bis dahin ist nichts zu tun.', + 'mail_action' => 'Zu Ihren Rechnungen', + 'mail_help' => 'Fragen zur Bestellung? Antworten Sie einfach auf diese Nachricht.', +]; diff --git a/lang/de/provisioning.php b/lang/de/provisioning.php index a4e6def..0f93bc6 100644 --- a/lang/de/provisioning.php +++ b/lang/de/provisioning.php @@ -20,6 +20,15 @@ return [ ], 'mail' => [ + 'ready_heading' => 'Ihre Cloud ist bereit', + 'ready_preheader' => 'Erreichbar unter :url', + 'ready_greeting_name' => 'Guten Tag :name,', + 'field_address' => 'Adresse', + 'field_plan' => 'Paket', + 'field_storage' => 'Speicher', + 'field_location' => 'Standort', + 'value_storage' => ':gb GB', + 'ready_help' => 'Fragen zur Einrichtung? Antworten Sie einfach auf diese Nachricht.', 'ready_subject' => 'Ihre CluPilot Cloud ist bereit', 'ready_greeting' => 'Willkommen bei CluPilot!', 'ready_line' => 'Ihre Cloud ist eingerichtet und einsatzbereit.', diff --git a/lang/en/maintenance.php b/lang/en/maintenance.php index 327a491..085af4e 100644 --- a/lang/en/maintenance.php +++ b/lang/en/maintenance.php @@ -58,6 +58,8 @@ return [ 'mail_heading' => 'Scheduled maintenance', 'mail_greeting' => 'Hello :name,', 'mail_window' => 'Window: :start to :end.', + 'mail_field_start' => 'Starts', + 'mail_field_end' => 'Ends', 'mail_no_action' => 'No action is required. Your data stays safe.', 'mail_signoff' => 'Best regards', diff --git a/lang/en/orders.php b/lang/en/orders.php new file mode 100644 index 0000000..bdef97d --- /dev/null +++ b/lang/en/orders.php @@ -0,0 +1,18 @@ + 'Your order :number', + 'mail_heading' => 'Order confirmed', + 'mail_preheader' => ':label — payment received.', + 'mail_greeting' => 'Hello :name,', + 'mail_intro' => 'thank you for your order. The payment has arrived and we are setting your environment up.', + 'field_product' => 'Product', + 'field_location' => 'Location', + 'field_number' => 'Order number', + 'field_amount' => 'Amount', + // Deliberately without a time: provisioning takes as long as it takes, and + // a promised minute that slips is worse than no promise at all. + 'mail_next' => 'You will get a second message with the address of your cloud once it is running. Nothing to do until then.', + 'mail_action' => 'View your invoices', + 'mail_help' => 'Questions about the order? Just reply to this message.', +]; diff --git a/lang/en/provisioning.php b/lang/en/provisioning.php index c081a06..a4d6ce0 100644 --- a/lang/en/provisioning.php +++ b/lang/en/provisioning.php @@ -20,6 +20,15 @@ return [ ], 'mail' => [ + 'ready_heading' => 'Your cloud is ready', + 'ready_preheader' => 'Reachable at :url', + 'ready_greeting_name' => 'Hello :name,', + 'field_address' => 'Address', + 'field_plan' => 'Plan', + 'field_storage' => 'Storage', + 'field_location' => 'Location', + 'value_storage' => ':gb GB', + 'ready_help' => 'Questions about the setup? Just reply to this message.', 'ready_subject' => 'Your CluPilot cloud is ready', 'ready_greeting' => 'Welcome to CluPilot!', 'ready_line' => 'Your cloud is set up and ready to use.', diff --git a/resources/views/mail/cloud-ready.blade.php b/resources/views/mail/cloud-ready.blade.php new file mode 100644 index 0000000..a2cab14 --- /dev/null +++ b/resources/views/mail/cloud-ready.blade.php @@ -0,0 +1,56 @@ + + + +

{{ __('provisioning.mail.ready_line') }}

+ + +{{-- What was actually built, as a table. Somebody who ordered a fortnight ago + is checking this against what they bought, and that is a comparison + rather than a sentence. --}} + + + + + + + + + + + + + + + + + + +
{{ __('provisioning.mail.field_address') }}{{ $url }}
{{ __('provisioning.mail.field_plan') }}{{ $plan }}
{{ __('provisioning.mail.field_storage') }}{{ $storage }}
{{ __('provisioning.mail.field_location') }}{{ $location }}
+ + +{{-- No password here, and the sentence says why rather than leaving the + recipient to hunt for one. It used to travel in this mail: across + third-party mail servers, then sitting in an inbox for ever. It now waits + behind the sign-in and is deleted once acknowledged. --}} + +

{{ __('provisioning.mail.ready_credentials') }}

+ + +
+ {{ __('provisioning.mail.ready_action') }} +
+ + + + + +
+

{{ __('provisioning.mail.ready_help') }}

+
+ + +
diff --git a/resources/views/mail/maintenance-announcement.blade.php b/resources/views/mail/maintenance-announcement.blade.php index d720586..a355b27 100644 --- a/resources/views/mail/maintenance-announcement.blade.php +++ b/resources/views/mail/maintenance-announcement.blade.php @@ -1,18 +1,41 @@ - -# {{ __('maintenance.mail_heading') }} + -{{ __('maintenance.mail_greeting', ['name' => $name]) }} + +

{{ $title }}

+ -**{{ $title }}** - -{{ __('maintenance.mail_window', ['start' => $startsAt->local()->isoFormat('LLLL'), 'end' => $endsAt->local()->isoFormat('LLLL')]) }} +{{-- The window as its own block rather than inside a sentence. The one thing + the reader is looking for is when, and a date buried in prose has to be + found before it can be read. --}} + + + + + + + + + + +
{{ __('maintenance.mail_field_start') }}{{ $startsAt->local()->isoFormat('LLLL') }}
{{ __('maintenance.mail_field_end') }}{{ $endsAt->local()->isoFormat('LLLL') }}
+ @if ($description) -{{ $description }} + +

{{ $description }}

+ @endif -{{ __('maintenance.mail_no_action') }} + + + +
+

{{ __('maintenance.mail_no_action') }}

+
+ -{{ __('maintenance.mail_signoff') }}
-{{ config('app.name') }} -
+ diff --git a/resources/views/mail/maintenance-cancelled.blade.php b/resources/views/mail/maintenance-cancelled.blade.php index 09ab5d3..4f611aa 100644 --- a/resources/views/mail/maintenance-cancelled.blade.php +++ b/resources/views/mail/maintenance-cancelled.blade.php @@ -1,10 +1,11 @@ - -# {{ __('maintenance.mail_cancel_heading') }} + -{{ __('maintenance.mail_greeting', ['name' => $name]) }} + +

{{ __('maintenance.mail_cancel_body', ['title' => $title]) }}

+ -{{ __('maintenance.mail_cancel_body', ['title' => $title]) }} - -{{ __('maintenance.mail_signoff') }}
-{{ config('app.name') }} -
+ diff --git a/resources/views/mail/order-confirmation.blade.php b/resources/views/mail/order-confirmation.blade.php new file mode 100644 index 0000000..354cae4 --- /dev/null +++ b/resources/views/mail/order-confirmation.blade.php @@ -0,0 +1,52 @@ + + + +

{{ __('orders.mail_intro') }}

+ + + + + + + + + + + + + + + + + + + + +
{{ __('orders.field_product') }}{{ $label }}
{{ __('orders.field_location') }}{{ $location }}
{{ __('orders.field_number') }}{{ $order->uuid ?? $order->id }}
{{ __('orders.field_amount') }}{{ $amount }}
+ + +{{-- What happens next, without naming a time. Provisioning takes as long as it + takes and CloudReady announces the end of it; a promised minute that slips + is worse than no promise. --}} + +

{{ __('orders.mail_next') }}

+ + +
+ {{ __('orders.mail_action') }} +
+ + + + + +
+

{{ __('orders.mail_help') }}

+
+ + +
diff --git a/tests/Feature/MailTemplatesTest.php b/tests/Feature/MailTemplatesTest.php new file mode 100644 index 0000000..6e25a99 --- /dev/null +++ b/tests/Feature/MailTemplatesTest.php @@ -0,0 +1,125 @@ +fromStripeEvent([ + 'id' => 'evt_'.uniqid(), + 'email' => 'kunde@example.com', + 'name' => 'Beispiel GmbH', + 'stripe_customer_id' => null, + 'plan' => 'start', + 'datacenter' => 'fsn', + 'amount_cents' => 1900, + 'currency' => 'EUR', + ]); + + Mail::assertQueued(OrderConfirmationMail::class, function (OrderConfirmationMail $mail) { + return $mail->hasTo('kunde@example.com'); + }); +}); + +it('does not confirm the same order twice when Stripe delivers twice', function () { + // Stripe retries until it gets a 2xx, so duplicate deliveries are ordinary + // rather than exceptional. A second confirmation for one payment reads as a + // second charge. + Mail::fake(); + Queue::fake(); + + $event = [ + 'id' => 'evt_duplicate', + 'email' => 'kunde@example.com', + 'name' => 'Beispiel GmbH', + 'stripe_customer_id' => null, + 'plan' => 'start', + 'datacenter' => 'fsn', + 'amount_cents' => 1900, + 'currency' => 'EUR', + ]; + + app(StartCustomerProvisioning::class)->fromStripeEvent($event); + app(StartCustomerProvisioning::class)->fromStripeEvent($event); + + Mail::assertQueuedCount(1); +}); + +it('renders the order confirmation without a template error, and with the money in it', function () { + // Rendering is the only way to find a Blade mistake in a mail: nothing else + // ever opens these files, and the first person who would is a customer. + $customer = Customer::factory()->create(['name' => 'Beispiel GmbH']); + $order = Order::factory()->create([ + 'customer_id' => $customer->id, + 'amount_cents' => 1900, + 'currency' => 'EUR', + 'datacenter' => 'fsn', + ]); + + $rendered = (new OrderConfirmationMail($order, 'Beispiel GmbH'))->render(); + + expect($rendered)->toContain('19,00 EUR') + ->and($rendered)->toContain(__('orders.mail_heading')) + // The footer every mail carries, from the shared layout. + ->and($rendered)->toContain(__('mail.why_you_got_this')); +}); + +it('keeps every mail template on the shared layout', function () { + // Laravel's markdown component brings its own logo, button and footer. Two + // templates were still wearing it, and a new one written in a hurry is one + // copy-paste away from doing the same — at which point the product sends + // mail that looks like two different companies. + $offenders = []; + + foreach (File::allFiles(resource_path('views/mail')) as $file) { + $contents = File::get($file->getPathname()); + + if (str_contains($contents, 'x-mail::message') || str_contains($contents, 'x-mail::')) { + $offenders[] = $file->getFilename(); + } + } + + expect($offenders)->toBe([]); +}); + +it('never puts a credential in the mail that announces a finished cloud', function () { + // The initial admin password used to travel there in clear text, across + // third-party mail servers and then into an inbox for ever. It waits behind + // the sign-in now, and this is what stops it drifting back. + // + // Asserted against the data the mail is actually given, not against the + // template's source. A source scan trips over the word in a comment and + // passes a template that leaks the value through a variable — it tests the + // prose, not the mail. + $secret = 'Str3ng-Geheim-'.uniqid(); + + $customer = Customer::factory()->create(); + $instance = App\Models\Instance::factory()->create([ + 'customer_id' => $customer->id, + 'admin_password' => $secret, + 'subdomain' => 'beispiel', + ]); + + $user = App\Models\User::factory()->create(['name' => 'Beispiel GmbH']); + + $message = (new App\Notifications\CloudReady($instance))->toMail($user); + + expect(json_encode($message->viewData))->not->toContain($secret); + + // And the same for what is rendered from it, so a template that reaches + // past its data for the model cannot slip through either. + expect(view($message->view, $message->viewData)->render())->not->toContain($secret); +});