Put every mail in one design, and confirm an order when the money arrives
Four templates, one layout. Two of them — the maintenance announcement and its cancellation — still wore Laravel's markdown component, which brings its own logo, its own button and its own footer: the product was sending mail that looked like two different companies, one of which was the framework. New: an order confirmation, sent when the payment lands 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. It deliberately promises no time for the cloud itself — provisioning takes what it takes, CloudReady announces the end, and a promised minute that slips is worse than no promise. Queued after the transaction, never inside it, and never allowed to fail the webhook: Stripe retries anything that is not a 2xx, and a retry would re-enter provisioning over a mail server having a bad minute. The finished-cloud mail now names what was actually built — address, plan, storage, location — because somebody who ordered a fortnight ago is checking it against what they bought, and that is a comparison rather than a sentence. Still no credential in it. Two tests earn their place. One holds every template on the shared layout, so a new one written in a hurry cannot copy-paste its way back to the framework's. The other proves the finished-cloud mail carries no password — asserted against the data the mail is given and the HTML it renders, not against the template's source, because the first version of that test tripped over the word in a comment and would have passed a template that leaked the value through a variable. It tested the prose, not the mail. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>feat/granted-plans
parent
eb743d34ed
commit
b1ab0f7f31
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,78 @@
|
|||
<?php
|
||||
|
||||
namespace App\Mail;
|
||||
|
||||
use App\Mail\Concerns\SendsFromMailbox;
|
||||
use App\Models\Order;
|
||||
use App\Services\Mail\MailPurpose;
|
||||
use Illuminate\Bus\Queueable;
|
||||
use Illuminate\Contracts\Queue\ShouldQueue;
|
||||
use Illuminate\Mail\Mailable;
|
||||
use Illuminate\Mail\Mailables\Content;
|
||||
use Illuminate\Mail\Mailables\Envelope;
|
||||
use Illuminate\Queue\SerializesModels;
|
||||
|
||||
/**
|
||||
* "We have your order, and here is what happens next."
|
||||
*
|
||||
* Sent when the payment lands, not when the machine is ready — those are
|
||||
* minutes apart at best and can be much longer, and a customer who has just
|
||||
* been charged and heard nothing assumes the worst about both.
|
||||
*
|
||||
* From the BILLING mailbox: it is a record of a payment, and a reply to it is
|
||||
* a question about money rather than about a server.
|
||||
*
|
||||
* Deliberately says nothing about when the cloud will be ready. Provisioning
|
||||
* takes as long as it takes, CloudReady announces the end of it, and a
|
||||
* promised time that slips is worse than no promise at all.
|
||||
*/
|
||||
class OrderConfirmationMail extends Mailable implements ShouldQueue
|
||||
{
|
||||
use Queueable, SendsFromMailbox, SerializesModels;
|
||||
|
||||
public function __construct(public Order $order, public string $name) {}
|
||||
|
||||
public function envelope(): Envelope
|
||||
{
|
||||
return $this->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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'mail_subject' => '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.',
|
||||
];
|
||||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
<?php
|
||||
|
||||
return [
|
||||
'mail_subject' => '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.',
|
||||
];
|
||||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -0,0 +1,56 @@
|
|||
<x-mail.layout
|
||||
:heading="__('provisioning.mail.ready_heading')"
|
||||
:preheader="__('provisioning.mail.ready_preheader', ['url' => $url])"
|
||||
:greeting="$name ? __('provisioning.mail.ready_greeting_name', ['name' => $name]) : null"
|
||||
>
|
||||
|
||||
<tr><td style="padding:0 40px 24px 40px;">
|
||||
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ __('provisioning.mail.ready_line') }}</p>
|
||||
</td></tr>
|
||||
|
||||
{{-- 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. --}}
|
||||
<tr><td style="padding:0 40px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;background-color:#fafafb;border:1px solid #e9e9ee;border-radius:8px;">
|
||||
<tr>
|
||||
<td style="padding:14px 16px 6px 16px;font-size:13px;line-height:20px;color:#6e6e7a;width:34%;">{{ __('provisioning.mail.field_address') }}</td>
|
||||
<td style="padding:14px 16px 6px 16px;font-family:'IBM Plex Mono',ui-monospace,Menlo,Consolas,monospace;font-size:13px;line-height:20px;color:#b8500a;word-break:break-all;">{{ $url }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:0 16px 6px 16px;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('provisioning.mail.field_plan') }}</td>
|
||||
<td style="padding:0 16px 6px 16px;font-size:13px;line-height:20px;color:#17171c;font-weight:600;">{{ $plan }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:0 16px 6px 16px;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('provisioning.mail.field_storage') }}</td>
|
||||
<td style="padding:0 16px 6px 16px;font-family:'IBM Plex Mono',ui-monospace,Menlo,Consolas,monospace;font-size:13px;line-height:20px;color:#43434e;">{{ $storage }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:0 16px 14px 16px;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('provisioning.mail.field_location') }}</td>
|
||||
<td style="padding:0 16px 14px 16px;font-size:13px;line-height:20px;color:#43434e;">{{ $location }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
|
||||
{{-- 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. --}}
|
||||
<tr><td style="padding:24px 40px 0 40px;">
|
||||
<p style="margin:0 0 16px 0;font-size:15px;line-height:24px;color:#43434e;">{{ __('provisioning.mail.ready_credentials') }}</p>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
<tr><td align="center" bgcolor="#b8500a" style="background-color:#b8500a;border-radius:8px;">
|
||||
<a href="{{ $dashboardUrl }}" style="display:inline-block;padding:13px 26px;font-family:'IBM Plex Sans',-apple-system,Helvetica,Arial,sans-serif;font-size:15px;line-height:20px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">{{ __('provisioning.mail.ready_action') }}</a>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
|
||||
<tr><td style="padding:24px 40px 32px 40px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
<tr><td style="border-top:1px solid #e9e9ee;padding-top:20px;">
|
||||
<p style="margin:0;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('provisioning.mail.ready_help') }}</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
|
||||
</x-mail.layout>
|
||||
|
|
@ -1,18 +1,41 @@
|
|||
<x-mail::message>
|
||||
# {{ __('maintenance.mail_heading') }}
|
||||
<x-mail.layout
|
||||
:heading="__('maintenance.mail_heading')"
|
||||
:preheader="$title"
|
||||
:greeting="__('maintenance.mail_greeting', ['name' => $name])"
|
||||
>
|
||||
|
||||
{{ __('maintenance.mail_greeting', ['name' => $name]) }}
|
||||
<tr><td style="padding:0 40px 20px 40px;">
|
||||
<p style="margin:0;font-size:15px;line-height:24px;color:#17171c;font-weight:600;">{{ $title }}</p>
|
||||
</td></tr>
|
||||
|
||||
**{{ $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. --}}
|
||||
<tr><td style="padding:0 40px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;background-color:#fafafb;border:1px solid #e9e9ee;border-radius:8px;">
|
||||
<tr>
|
||||
<td style="padding:14px 16px 6px 16px;font-size:13px;line-height:20px;color:#6e6e7a;width:26%;">{{ __('maintenance.mail_field_start') }}</td>
|
||||
<td style="padding:14px 16px 6px 16px;font-family:'IBM Plex Mono',ui-monospace,Menlo,Consolas,monospace;font-size:13px;line-height:20px;color:#43434e;">{{ $startsAt->local()->isoFormat('LLLL') }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:0 16px 14px 16px;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('maintenance.mail_field_end') }}</td>
|
||||
<td style="padding:0 16px 14px 16px;font-family:'IBM Plex Mono',ui-monospace,Menlo,Consolas,monospace;font-size:13px;line-height:20px;color:#43434e;">{{ $endsAt->local()->isoFormat('LLLL') }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
|
||||
@if ($description)
|
||||
{{ $description }}
|
||||
<tr><td style="padding:20px 40px 0 40px;">
|
||||
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ $description }}</p>
|
||||
</td></tr>
|
||||
@endif
|
||||
|
||||
{{ __('maintenance.mail_no_action') }}
|
||||
<tr><td style="padding:24px 40px 32px 40px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
<tr><td style="border-top:1px solid #e9e9ee;padding-top:20px;">
|
||||
<p style="margin:0;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('maintenance.mail_no_action') }}</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
|
||||
{{ __('maintenance.mail_signoff') }}<br>
|
||||
{{ config('app.name') }}
|
||||
</x-mail::message>
|
||||
</x-mail.layout>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,11 @@
|
|||
<x-mail::message>
|
||||
# {{ __('maintenance.mail_cancel_heading') }}
|
||||
<x-mail.layout
|
||||
:heading="__('maintenance.mail_cancel_heading')"
|
||||
:preheader="$title"
|
||||
:greeting="__('maintenance.mail_greeting', ['name' => $name])"
|
||||
>
|
||||
|
||||
{{ __('maintenance.mail_greeting', ['name' => $name]) }}
|
||||
<tr><td style="padding:0 40px 32px 40px;">
|
||||
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ __('maintenance.mail_cancel_body', ['title' => $title]) }}</p>
|
||||
</td></tr>
|
||||
|
||||
{{ __('maintenance.mail_cancel_body', ['title' => $title]) }}
|
||||
|
||||
{{ __('maintenance.mail_signoff') }}<br>
|
||||
{{ config('app.name') }}
|
||||
</x-mail::message>
|
||||
</x-mail.layout>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,52 @@
|
|||
<x-mail.layout
|
||||
:heading="__('orders.mail_heading')"
|
||||
:preheader="__('orders.mail_preheader', ['label' => $label])"
|
||||
:greeting="$name ? __('orders.mail_greeting', ['name' => $name]) : null"
|
||||
>
|
||||
|
||||
<tr><td style="padding:0 40px 24px 40px;">
|
||||
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ __('orders.mail_intro') }}</p>
|
||||
</td></tr>
|
||||
|
||||
<tr><td style="padding:0 40px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;background-color:#fafafb;border:1px solid #e9e9ee;border-radius:8px;">
|
||||
<tr>
|
||||
<td style="padding:14px 16px 6px 16px;font-size:13px;line-height:20px;color:#6e6e7a;width:34%;">{{ __('orders.field_product') }}</td>
|
||||
<td style="padding:14px 16px 6px 16px;font-size:13px;line-height:20px;color:#17171c;font-weight:600;">{{ $label }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:0 16px 6px 16px;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('orders.field_location') }}</td>
|
||||
<td style="padding:0 16px 6px 16px;font-size:13px;line-height:20px;color:#43434e;">{{ $location }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:0 16px 6px 16px;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('orders.field_number') }}</td>
|
||||
<td style="padding:0 16px 6px 16px;font-family:'IBM Plex Mono',ui-monospace,Menlo,Consolas,monospace;font-size:13px;line-height:20px;color:#43434e;">{{ $order->uuid ?? $order->id }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="padding:0 16px 14px 16px;font-size:13px;line-height:20px;color:#6e6e7a;border-top:1px solid #e9e9ee;padding-top:10px;">{{ __('orders.field_amount') }}</td>
|
||||
<td style="padding:0 16px 14px 16px;font-family:'IBM Plex Mono',ui-monospace,Menlo,Consolas,monospace;font-size:15px;line-height:20px;color:#17171c;font-weight:600;border-top:1px solid #e9e9ee;padding-top:10px;">{{ $amount }}</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
|
||||
{{-- 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. --}}
|
||||
<tr><td style="padding:24px 40px 0 40px;">
|
||||
<p style="margin:0 0 16px 0;font-size:15px;line-height:24px;color:#43434e;">{{ __('orders.mail_next') }}</p>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
<tr><td align="center" bgcolor="#b8500a" style="background-color:#b8500a;border-radius:8px;">
|
||||
<a href="{{ $invoicesUrl }}" style="display:inline-block;padding:13px 26px;font-family:'IBM Plex Sans',-apple-system,Helvetica,Arial,sans-serif;font-size:15px;line-height:20px;font-weight:600;color:#ffffff;text-decoration:none;border-radius:8px;">{{ __('orders.mail_action') }}</a>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
|
||||
<tr><td style="padding:24px 40px 32px 40px;">
|
||||
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
|
||||
<tr><td style="border-top:1px solid #e9e9ee;padding-top:20px;">
|
||||
<p style="margin:0;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('orders.mail_help') }}</p>
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
|
||||
</x-mail.layout>
|
||||
|
|
@ -0,0 +1,125 @@
|
|||
<?php
|
||||
|
||||
use App\Actions\StartCustomerProvisioning;
|
||||
use App\Mail\OrderConfirmationMail;
|
||||
use App\Models\Customer;
|
||||
use App\Models\Order;
|
||||
use Illuminate\Support\Facades\File;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Illuminate\Support\Facades\Queue;
|
||||
|
||||
/**
|
||||
* The mails this product sends, and the one layout they all wear.
|
||||
*/
|
||||
it('sends an order confirmation the moment the payment lands', function () {
|
||||
// Not when provisioning finishes. Those are minutes apart at best, and
|
||||
// somebody who has just been charged and heard nothing assumes the worst
|
||||
// about both the charge and the product.
|
||||
Mail::fake();
|
||||
Queue::fake();
|
||||
|
||||
app(StartCustomerProvisioning::class)->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);
|
||||
});
|
||||
Loading…
Reference in New Issue