Compare commits

...

2 Commits

Author SHA1 Message Date
nexxo 4ac9fb1d03 Release v1.3.51
tests / pest (push) Failing after 8m28s Details
tests / assets (push) Successful in 28s Details
tests / release (push) Has been skipped Details
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:53:07 +02:00
nexxo c354ad1463 Make mails readable on a phone, and let an operator look at one
Three things the owner reported, and one sentence that promised something
this product does not do.

**The mails had a fixed width.** components/mail/layout.blade.php set
width="600" and width:600px, so every phone showed a 600px canvas in a
390px window and one had to scroll sideways to read a sentence. Now the
table is width="100%" with max-width:600px — fluid, with 600 as a ceiling
rather than a floor. A mail cannot fix this with a media query (Outlook
renders with Word and drops <style> blocks), which is why the whole file
is inline attributes and why the shrinking has to be built in. Side
padding went 40px -> 24px in the layout and in all nine mail views:
40 each side leaves 278px of a 390px screen to read in.

**There was no way to look at a mail.** An invoice mail needs an invoice,
a maintenance announcement needs a window, and "register an account to
see whether the confirmation reads well" is not a workflow — which is how
the fixed width survived for months. App\Services\Mail\MailPreviews
builds all nine from sample data, every record with make() and never
create(), so a preview leaves no invoice, order or window behind and
draws no number from a series. Two ways to see one: in a tab as the
document itself, or sent to the signed-in operator's own address. There
is no recipient field — that would be a form for mailing strangers.

sendNow(), not send(): every mailable here implements ShouldQueue, and
send() defers to the queue for those, which would have put a failure in a
worker's log while the button reported success to somebody waiting for
the mail. The test caught that.

**The booking page had no shell.** Order declared layouts.portal — the
bare shell the sign-in pages use, no navigation and no padding — so the
page sat flush against the top and bottom of the window with no way back
to anything. layouts.portal-app, like every other portal page.

**The consent box said something untrue.** Its refusal read "otherwise we
can only build your cloud once the withdrawal period has ended", which
describes a path that does not exist: nothing here delivers fourteen days
later, and an unticked box simply means no order. Said plainly now, and
said on the page before somebody runs into the refusal.

Also: GrantedPlanPortalTest asserted assertDontSee('179'), which matches
any id containing those digits — and ids climb through a suite run
because a rolled-back transaction does not reset an auto-increment
counter. It passed alone and failed in company. Asserts on '179,00' now,
which is what a leaked price would actually look like.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 14:50:37 +02:00
29 changed files with 583 additions and 38 deletions

View File

@ -1 +1 @@
1.3.50
1.3.51

View File

@ -0,0 +1,82 @@
<?php
namespace App\Livewire\Admin;
use App\Services\Mail\MailPreviews;
use Illuminate\Support\Facades\Auth;
use Illuminate\Support\Facades\Mail;
use Livewire\Attributes\Layout;
use Livewire\Component;
use Throwable;
/**
* Look at a mail before a customer does.
*
* There was no way to. An invoice mail needs an invoice, a maintenance
* announcement needs a window, and "register an account to see whether the
* confirmation reads well on a phone" is not a workflow — which is how every
* mail went out with a hard 600px table until somebody opened one on a
* telephone and had to scroll sideways to read a sentence.
*
* Two ways to look: in the browser, which answers the desktop question at once,
* and as a real mail to the operator's OWN address, which is the only way to
* answer the phone question. Never to a customer: a preview is for us.
*/
#[Layout('layouts.admin')]
class MailPreview extends Component
{
public function mount(): void
{
$this->authorize('mail.manage');
}
/**
* Send one to the signed-in operator.
*
* To themselves and nobody else no address field. A preview that can be
* addressed is a form for sending mail to strangers from inside the console,
* which is not what this is for and not something to leave lying about.
*/
public function sendToMe(string $key): void
{
$this->authorize('mail.manage');
$operator = Auth::guard('operator')->user();
$mailable = app(MailPreviews::class)->make($key);
if ($operator === null || $mailable === null) {
return;
}
try {
// sendNow(), not send(): every mailable here implements ShouldQueue,
// and send() silently defers to queue() for those — which would put
// a failure in a worker's log while the button reported success. The
// person pressing it is waiting for the mail, so the send happens in
// their request and its failure reaches them.
Mail::to($operator->email)->sendNow($mailable);
} catch (Throwable $e) {
$this->dispatch('notify', message: __('mail_preview.failed', [
'reason' => mb_substr($e->getMessage(), 0, 160),
]));
return;
}
$this->dispatch('notify', message: __('mail_preview.sent', ['email' => $operator->email]));
}
public function render()
{
$this->authorize('mail.manage');
return view('livewire.admin.mail-preview', [
'previews' => app(MailPreviews::class)->all(),
'me' => Auth::guard('operator')->user()?->email ?? '',
// A preview that goes nowhere is worse than none: with a
// non-delivering mailer the browser view still works and the send
// silently writes to a file.
'delivers' => ! in_array(config('mail.default'), ['log', 'array', null], true),
]);
}
}

View File

@ -34,7 +34,12 @@ use Throwable;
* purchase becomes real on the webhook, which is the only place allowed to
* believe a payment happened.
*/
#[Layout('layouts.portal')]
// layouts.portal is the bare shell the SIGN-IN pages use: no navigation, no
// page padding. This page is reached signed in and behind `verified`, so it
// belongs in the app shell like every other portal page — Dashboard and Billing
// were already there. In the wrong one it rendered flush against the top and
// bottom of the window with no way back to anything.
#[Layout('layouts.portal-app')]
class Order extends Component
{
public function render()

View File

@ -0,0 +1,205 @@
<?php
namespace App\Services\Mail;
use App\Mail\InvoiceMail;
use App\Mail\MaintenanceAnnouncementMail;
use App\Mail\MaintenanceCancelledMail;
use App\Mail\NewDeviceSignInMail;
use App\Mail\OperatorMessageMail;
use App\Mail\OrderConfirmationMail;
use App\Mail\ResetPasswordMail;
use App\Mail\VerifyEmailMail;
use App\Models\Customer;
use App\Models\Invoice;
use App\Models\MaintenanceWindow;
use App\Models\Order;
use App\Models\User;
use App\Models\UserDevice;
use Illuminate\Mail\Mailable;
use Illuminate\Support\Carbon;
/**
* Every mail this application sends, built from sample data so it can be looked
* at without waiting for the event that would send it.
*
* There is no other way to see most of these. An invoice mail needs an invoice, a
* maintenance announcement needs a window, and "register an account to check
* whether the confirmation reads well on a phone" is not a workflow — which is
* how a mail with a hard 600px table went out for months before anybody opened
* one on a telephone.
*
* ## Nothing is written
*
* Every record here is built with `make()`, never `create()`: a preview must not
* leave an invoice, an order or a maintenance window behind in the database. The
* models exist for the length of one render.
*
* The one exception is by omission: where a real record already exists, it is
* NOT used. Rendering somebody's actual invoice to check a layout puts their
* figures in an operator's inbox for no reason.
*/
class MailPreviews
{
/**
* The list, in the order somebody would read them: sign-up, purchase,
* operation, money, security.
*
* @return array<string, string> key => label
*/
public function all(): array
{
return [
'verify-email' => 'E-Mail bestätigen (Registrierung)',
'reset-password' => 'Passwort zurücksetzen',
'order-confirmation' => 'Bestellbestätigung',
'cloud-ready' => 'Cloud ist bereit (Zugangsdaten)',
'maintenance-announcement' => 'Wartungsfenster angekündigt',
'maintenance-cancelled' => 'Wartungsfenster abgesagt',
'invoice' => 'Rechnung',
'new-device' => 'Anmeldung von einem neuen Gerät',
'operator-message' => 'Nachricht aus der Konsole',
];
}
public function has(string $key): bool
{
return array_key_exists($key, $this->all());
}
/**
* One mailable, filled with sample data.
*
* Returns null for a key nobody knows rather than throwing: this is reached
* from a URL, and a URL is a string somebody can retype.
*/
public function make(string $key): ?Mailable
{
if (! $this->has($key)) {
return null;
}
$customer = $this->customer();
$user = $this->user();
return match ($key) {
'verify-email' => new VerifyEmailMail($user),
'reset-password' => new ResetPasswordMail($user, 'https://example.test/reset/beispiel-token', 60),
'order-confirmation' => new OrderConfirmationMail($this->order($customer), $customer->name),
'cloud-ready' => $this->cloudReady($customer),
'maintenance-announcement' => new MaintenanceAnnouncementMail($this->window(), $customer),
'maintenance-cancelled' => new MaintenanceCancelledMail($this->window(), $customer),
'invoice' => new InvoiceMail($this->invoice($customer), $customer->name),
'new-device' => new NewDeviceSignInMail($customer->name, $this->device($user), 'web'),
'operator-message' => new OperatorMessageMail(
$customer,
'Zur Datenübernahme',
"Guten Tag Bea Berger,\n\nich habe mir Ihre Ausgangslage angesehen. Die Übernahme ist möglich.\n\nMit freundlichen Grüßen\nCluPilot",
),
default => null,
};
}
// ---- The sample records. make(), never create(). ----
private function customer(): Customer
{
return Customer::make([
'name' => 'Beispiel GmbH',
'contact_name' => 'Bea Berger',
'email' => 'bea@beispiel.test',
'locale' => 'de',
]);
}
private function user(): User
{
// The verification mail signs its URL from the model key, so the sample
// needs one — 0 is enough for a link nobody is meant to follow.
return User::make(['name' => 'Bea Berger', 'email' => 'bea@beispiel.test'])
->forceFill(['id' => 0, 'email_verified_at' => null]);
}
private function order(Customer $customer): Order
{
return Order::make([
'plan' => 'start',
'amount_cents' => 4900,
'currency' => 'EUR',
'datacenter' => 'fsn',
'status' => 'paid',
])->forceFill(['id' => 0, 'created_at' => Carbon::now()]);
}
private function invoice(Customer $customer): Invoice
{
// The document renders from its own snapshot, so a preview only needs a
// convincing snapshot — not a series, not a number drawn from one.
return Invoice::make([
'number' => 'RE-2026-0042',
'issued_on' => Carbon::now()->toDateString(),
'due_on' => Carbon::now()->addDays(14)->toDateString(),
'net_cents' => 4900,
'tax_cents' => 980,
'gross_cents' => 5880,
'currency' => 'EUR',
'snapshot' => ['meta' => ['number' => 'RE-2026-0042']],
])->forceFill(['id' => 0, 'uuid' => '00000000-0000-0000-0000-000000000000']);
}
private function window(): MaintenanceWindow
{
return MaintenanceWindow::make([
'title' => 'Sicherheitsupdates Nextcloud',
'starts_at' => Carbon::now()->addDays(3)->setTime(2, 0),
'ends_at' => Carbon::now()->addDays(3)->setTime(4, 0),
'state' => 'scheduled',
])->forceFill(['id' => 0]);
}
private function device(User $user): UserDevice
{
return UserDevice::make([
'guard' => 'web',
'name' => 'Firefox auf Linux',
'last_ip' => '203.0.113.42',
'last_seen_at' => Carbon::now(),
])->forceFill(['id' => 0]);
}
/**
* The credentials mail is a notification rather than a Mailable.
*
* Built through its own toMail() so the preview shows the real thing, not a
* second copy of the wording that would then drift.
*/
private function cloudReady(Customer $customer): Mailable
{
$mail = new class($customer) extends Mailable
{
public function __construct(public Customer $customer) {}
public function envelope(): \Illuminate\Mail\Mailables\Envelope
{
return new \Illuminate\Mail\Mailables\Envelope(subject: __('provisioning.mail.ready_subject'));
}
public function content(): \Illuminate\Mail\Mailables\Content
{
// The same keys App\Notifications\CloudReady hands the view. A
// preview that invented its own would render a mail nobody ever
// receives, and drift from the real one without saying so.
return new \Illuminate\Mail\Mailables\Content(view: 'mail.cloud-ready', with: [
'name' => $this->customer->name,
'url' => 'https://beispiel.clupilot.cloud',
'plan' => 'start',
'storage' => __('provisioning.mail.value_storage', ['gb' => 100]),
'location' => 'Falkenstein, Deutschland',
'dashboardUrl' => route('dashboard'),
]);
}
};
return $mail;
}
}

View File

@ -72,6 +72,10 @@ final class Navigation
]],
['label' => __('admin.nav_group.system'), 'items' => [
['admin.mail', 'mail', 'mail', 'mail.manage'],
// Looking at a mail before a customer does. Beside the mailboxes
// it is sent from, not under Betrieb: both are about how mail
// leaves this installation.
['admin.mail.preview', 'send', 'mail_preview', 'mail.manage'],
// System, not Betrieb: the tunnel is how the console reaches
// the estate at all. Nothing is provisioned, monitored or
// updated through anything else, so it belongs beside the other

View File

@ -11,6 +11,7 @@ return [
],
'nav' => [
'mail_preview' => 'E-Mail-Vorschau',
'inbox' => 'Posteingang',
'mail_log' => 'Postausgang',
'templates' => 'E-Mail-Vorlagen',

View File

@ -23,5 +23,10 @@ return [
// Lasten ginge. Von der Zustimmung hängt jetzt nichts mehr ab; sie bleibt
// als Beleg dafür, dass der Kunde den sofortigen Beginn verlangt hat.
'immediate_start' => 'Ich verlange ausdrücklich, dass Sie mit der Leistung schon vor Ablauf der 14-tägigen Widerrufsfrist beginnen. Mein Widerrufsrecht bleibt davon unberührt: Bei einem Widerruf innerhalb der Frist erhalte ich den gesamten gezahlten Betrag zurück.',
'immediate_start_required' => 'Bitte bestätigen Sie, dass die Leistung sofort beginnen soll — sonst können wir Ihre Cloud erst nach Ablauf der Widerrufsfrist einrichten.',
// Sagt, was ohne die Bestätigung passiert: die Bestellung kommt nicht
// zustande. Der Satz stand einmal anders da („dann richten wir erst nach
// Ablauf der Frist ein") und versprach damit einen Weg, den es nicht gibt —
// eine Cloud wird nach der Zahlung eingerichtet und nicht vierzehn Tage
// später.
'immediate_start_required' => 'Ohne diese Bestätigung kommt die Bestellung nicht zustande — Ihre Cloud wird unmittelbar nach der Zahlung eingerichtet, einen Beginn erst nach vierzehn Tagen gibt es nicht. Ihr Widerrufsrecht bleibt davon unberührt: Bei einem Widerruf innerhalb der Frist erhalten Sie den gesamten Betrag zurück.',
];

12
lang/de/mail_preview.php Normal file
View File

@ -0,0 +1,12 @@
<?php
// Eine Mail ansehen, bevor ein Kunde sie sieht.
return [
'title' => 'E-Mail-Vorschau',
'subtitle' => 'Jede Nachricht, die diese Installation verschickt, mit Beispieldaten gefüllt. Im Browser ansehen beantwortet die Frage am Rechner; als echte Mail an sich selbst schicken ist der einzige Weg, es am Telefon zu beurteilen.',
'open' => 'Im Browser',
'send' => 'An mich senden',
'sent' => 'Vorschau an :email geschickt.',
'failed' => 'Konnte nicht geschickt werden: :reason',
'note' => 'Gesendet wird immer an Ihre eigene Adresse (:email) — es gibt bewusst kein Empfängerfeld. Die Beispieldaten sind erfunden und werden nicht gespeichert.',
];

View File

@ -9,6 +9,7 @@ return [
'recommended' => 'Empfohlen',
'consent_title' => 'Sofortiger Beginn — bitte bestätigen',
'consent_why' => 'Ohne diese Bestätigung kommt die Bestellung nicht zustande. Es gibt keinen späteren Beginn: Ihre Cloud wird unmittelbar nach der Zahlung eingerichtet — und Ihr Widerrufsrecht behalten Sie vollständig.',
'consent_first' => 'Bitte oben zuerst bestätigen.',
'up_to' => 'bis :count',
'per_month' => '/Monat',

View File

@ -11,6 +11,7 @@ return [
],
'nav' => [
'mail_preview' => 'Mail preview',
'inbox' => 'Inbox',
'mail_log' => 'Mail sent',
'templates' => 'Mail templates',

View File

@ -21,5 +21,5 @@ return [
// even when it is against our own interest. Nothing turns on the consent
// now; it stays as a record that the customer asked us to start at once.
'immediate_start' => 'I expressly request that you begin providing the service before the 14-day withdrawal period has ended. My right of withdrawal is unaffected: if I withdraw within the period I get the full amount paid back.',
'immediate_start_required' => 'Please confirm that the service should begin at once — otherwise we can only build your cloud once the withdrawal period has ended.',
'immediate_start_required' => 'Without this confirmation the order cannot go through — your cloud is built as soon as the payment lands, and there is no start fourteen days later. Your right of withdrawal is unaffected: if you withdraw within the period you get the full amount back.',
];

12
lang/en/mail_preview.php Normal file
View File

@ -0,0 +1,12 @@
<?php
// Looking at a mail before a customer does.
return [
'title' => 'Mail preview',
'subtitle' => 'Every message this installation sends, filled with sample data. Opening it in the browser answers the desktop question; sending it to yourself as a real mail is the only way to judge it on a phone.',
'open' => 'In the browser',
'send' => 'Send to me',
'sent' => 'Preview sent to :email.',
'failed' => 'Could not be sent: :reason',
'note' => 'Always sent to your own address (:email) — there is deliberately no recipient field. The sample data is invented and is not stored.',
];

View File

@ -9,6 +9,7 @@ return [
'recommended' => 'Recommended',
'consent_title' => 'Immediate start — please confirm',
'consent_why' => 'Without this confirmation the order cannot go through. There is no later start: your cloud is built as soon as the payment lands — and you keep your right of withdrawal in full.',
'consent_first' => 'Please confirm above first.',
'up_to' => 'up to :count',
'per_month' => '/month',

View File

@ -28,7 +28,12 @@
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="background-color:#f6f6f8;border-collapse:collapse;">
<tr><td align="center" style="padding:32px 16px;">
<table role="presentation" width="600" cellpadding="0" cellspacing="0" border="0" style="width:600px;max-width:600px;border-collapse:collapse;font-family:'IBM Plex Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;">
{{-- FLUID, with 600 as a ceiling rather than a floor. width="600" plus
width:600px forced every phone to scroll sideways to read a sentence
reported exactly that way. A mail cannot rely on a media query (Outlook
renders with Word and drops <style> blocks, which is why this file is
inline attributes throughout), so the layout has to shrink by itself. --}}
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="width:100%;max-width:600px;border-collapse:collapse;font-family:'IBM Plex Sans',-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;">
<tr><td style="padding:0 0 20px 4px;">
<table role="presentation" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
@ -48,7 +53,7 @@
<tr><td style="background-color:#ffffff;border:1px solid #e9e9ee;border-radius:12px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0" border="0" style="border-collapse:collapse;">
<tr><td style="padding:40px 40px 0 40px;">
<tr><td style="padding:40px 24px 0 24px;">
<h1 style="margin:0 0 14px 0;font-size:25px;line-height:32px;font-weight:700;color:#17171c;letter-spacing:-0.5px;">{{ $heading }}</h1>
@if ($greeting)
<p style="margin:0 0 8px 0;font-size:15px;line-height:24px;color:#43434e;">{{ $greeting }}</p>

View File

@ -0,0 +1,42 @@
<div class="mx-auto max-w-[1120px] space-y-5">
<div class="animate-rise">
<h1 class="text-2xl font-bold tracking-tight text-ink">{{ __('mail_preview.title') }}</h1>
<p class="mt-1 max-w-[70ch] text-sm leading-relaxed text-muted">{{ __('mail_preview.subtitle') }}</p>
</div>
{{-- A preview that goes nowhere is worse than none: the browser view still
works, and "senden" would write to a file and report success. --}}
@if (! $delivers)
<x-ui.alert variant="warning">
{{ __('admin.notice.mail_not_delivering', ['mailer' => (string) (config('mail.default') ?? 'null')]) }}
</x-ui.alert>
@endif
<div class="overflow-hidden rounded-lg border border-line bg-surface shadow-xs animate-rise [animation-delay:60ms]">
<ul class="divide-y divide-line">
@foreach ($previews as $key => $label)
<li wire:key="prev-{{ $key }}" class="flex flex-wrap items-center gap-3 px-6 py-4">
<div class="min-w-0 flex-1">
<p class="text-sm font-medium text-ink">{{ $label }}</p>
<p class="font-mono text-xs text-muted">{{ $key }}</p>
</div>
{{-- A new tab, not an iframe: a mail is a whole document with
its own background, and judging it inside a console page
judges the console's frame around it. --}}
<x-ui.button :href="route('admin.mail.preview.show', $key)" variant="secondary" size="sm" target="_blank">
<x-ui.icon name="external-link" class="size-4" />{{ __('mail_preview.open') }}
</x-ui.button>
<x-ui.button wire:click="sendToMe('{{ $key }}')" variant="primary" size="sm"
wire:loading.attr="disabled" wire:target="sendToMe('{{ $key }}')"
:disabled="! $delivers">
<x-ui.icon name="send" class="size-4" />{{ __('mail_preview.send') }}
</x-ui.button>
</li>
@endforeach
</ul>
</div>
<p class="text-xs leading-relaxed text-muted">{{ __('mail_preview.note', ['email' => $me]) }}</p>
</div>

View File

@ -51,6 +51,12 @@
<span class="min-w-0">
<span class="block text-sm font-semibold text-ink">{{ __('order.consent_title') }}</span>
<span class="mt-1 block text-xs leading-relaxed text-body">{{ __('checkout.immediate_start') }}</span>
{{-- What happens if it is left unticked, said here rather than
only in the refusal afterwards. The box reads like a
choice between "now" and "in fourteen days", and there is
no second option: nothing here delivers later, so an
unticked box means no order at all. --}}
<span class="mt-2 block text-xs leading-relaxed text-muted">{{ __('order.consent_why') }}</span>
</span>
</label>

View File

@ -4,14 +4,14 @@
:greeting="$name ? __('provisioning.mail.ready_greeting_name', ['name' => $name]) : null"
>
<tr><td style="padding:0 40px 24px 40px;">
<tr><td style="padding:0 24px 24px 24px;">
<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;">
<tr><td style="padding:0 24px;">
<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>
@ -36,7 +36,7 @@
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;">
<tr><td style="padding:24px 24px 0 24px;">
<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;">
@ -45,7 +45,7 @@
</table>
</td></tr>
<tr><td style="padding:24px 40px 32px 40px;">
<tr><td style="padding:24px 24px 32px 24px;">
<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>

View File

@ -4,11 +4,11 @@
:greeting="$name ? __('invoice_mail.greeting', ['name' => $name]) : null"
>
<tr><td style="padding:0 40px 24px 40px;">
<tr><td style="padding:0 24px 24px 24px;">
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ __('invoice_mail.intro') }}</p>
</td></tr>
<tr><td style="padding:0 40px;">
<tr><td style="padding:0 24px;">
<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:38%;">{{ __('invoice_mail.field_number') }}</td>
@ -32,12 +32,12 @@
</td></tr>
@if ($paymentTerms)
<tr><td style="padding:20px 40px 0 40px;">
<tr><td style="padding:20px 24px 0 24px;">
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ $paymentTerms }}</p>
</td></tr>
@endif
<tr><td style="padding:24px 40px 0 40px;">
<tr><td style="padding:24px 24px 0 24px;">
<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;">{{ __('invoice_mail.action') }}</a>
@ -45,7 +45,7 @@
</table>
</td></tr>
<tr><td style="padding:24px 40px 32px 40px;">
<tr><td style="padding:24px 24px 32px 24px;">
<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;">{{ __('invoice_mail.attached') }}</p>

View File

@ -4,14 +4,14 @@
:greeting="__('maintenance.mail_greeting', ['name' => $name])"
>
<tr><td style="padding:0 40px 20px 40px;">
<tr><td style="padding:0 24px 20px 24px;">
<p style="margin:0;font-size:15px;line-height:24px;color:#17171c;font-weight:600;">{{ $title }}</p>
</td></tr>
{{-- 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;">
<tr><td style="padding:0 24px;">
<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>
@ -25,12 +25,12 @@
</td></tr>
@if ($description)
<tr><td style="padding:20px 40px 0 40px;">
<tr><td style="padding:20px 24px 0 24px;">
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ $description }}</p>
</td></tr>
@endif
<tr><td style="padding:24px 40px 32px 40px;">
<tr><td style="padding:24px 24px 32px 24px;">
<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>

View File

@ -4,7 +4,7 @@
:greeting="__('maintenance.mail_greeting', ['name' => $name])"
>
<tr><td style="padding:0 40px 32px 40px;">
<tr><td style="padding:0 24px 32px 24px;">
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ __('maintenance.mail_cancel_body', ['title' => $title]) }}</p>
</td></tr>

View File

@ -4,13 +4,13 @@
:greeting="$name !== '' ? __('devices.mail_greeting', ['name' => $name]) : null"
>
<tr><td style="padding:0 40px 24px 40px;">
<tr><td style="padding:0 24px 24px 24px;">
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ __('devices.mail_intro') }}</p>
</td></tr>
{{-- The facts, as a table rather than a sentence: the reader is checking
whether this was them, and that is a comparison, not a story. --}}
<tr><td style="padding:0 40px;">
<tr><td style="padding:0 24px;">
<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:38%;">{{ __('devices.field_device') }}</td>
@ -27,7 +27,7 @@
</table>
</td></tr>
<tr><td style="padding:24px 40px 0 40px;">
<tr><td style="padding:24px 24px 0 24px;">
<p style="margin:0 0 16px 0;font-size:15px;line-height:24px;color:#43434e;">{{ __('devices.mail_if_you') }}</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;">
@ -36,7 +36,7 @@
</table>
</td></tr>
<tr><td style="padding:24px 40px 32px 40px;">
<tr><td style="padding:24px 24px 32px 24px;">
<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;">{{ __('devices.mail_false_alarm') }}</p>

View File

@ -4,7 +4,7 @@
:greeting="__('customer_message.mail_greeting', ['name' => $name])"
>
<tr><td style="padding:0 40px 32px 40px;">
<tr><td style="padding:0 24px 32px 24px;">
{{-- 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

View File

@ -4,11 +4,11 @@
:greeting="$name ? __('orders.mail_greeting', ['name' => $name]) : null"
>
<tr><td style="padding:0 40px 24px 40px;">
<tr><td style="padding:0 24px 24px 24px;">
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ __('orders.mail_intro') }}</p>
</td></tr>
<tr><td style="padding:0 40px;">
<tr><td style="padding:0 24px;">
<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>
@ -32,7 +32,7 @@
{{-- 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;">
<tr><td style="padding:24px 24px 0 24px;">
<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;">
@ -41,7 +41,7 @@
</table>
</td></tr>
<tr><td style="padding:24px 40px 32px 40px;">
<tr><td style="padding:24px 24px 32px 24px;">
<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>

View File

@ -4,11 +4,11 @@
:greeting="$name ? __('reset_password.greeting', ['name' => $name]) : null"
>
<tr><td style="padding:0 40px 24px 40px;">
<tr><td style="padding:0 24px 24px 24px;">
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ __('reset_password.intro') }}</p>
</td></tr>
<tr><td style="padding:0 40px;">
<tr><td style="padding:0 24px;">
<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="{{ $url }}" 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;">{{ __('reset_password.action') }}</a>
@ -21,7 +21,7 @@
corporate mail gateways rewrite button links and some of them fetch the
target first to scan it, which on a single-use link spends it before the
recipient ever clicks. --}}
<tr><td style="padding:28px 40px 0 40px;">
<tr><td style="padding:28px 24px 0 24px;">
<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;">
<p style="margin:0 0 6px 0;font-size:12px;line-height:16px;color:#6e6e7a;">{{ __('reset_password.fallback') }}</p>
@ -34,7 +34,7 @@
NOT ask for it has just learned that a stranger knows their address, and
the right advice is "do nothing" not "contact us immediately", which is
what a phishing copy would say. --}}
<tr><td style="padding:24px 40px 0 40px;">
<tr><td style="padding:24px 24px 0 24px;">
<p style="margin:0;font-size:13px;line-height:20px;color:#6e6e7a;">{{ __('reset_password.not_you') }}</p>
</td></tr>

View File

@ -4,11 +4,11 @@
:greeting="$name ? __('verify_email.greeting', ['name' => $name]) : null"
>
<tr><td style="padding:0 40px 24px 40px;">
<tr><td style="padding:0 24px 24px 24px;">
<p style="margin:0;font-size:15px;line-height:24px;color:#43434e;">{{ __('verify_email.intro') }}</p>
</td></tr>
<tr><td style="padding:0 40px;">
<tr><td style="padding:0 24px;">
<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="{{ $url }}" 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;">{{ __('verify_email.action') }}</a>
@ -20,7 +20,7 @@
{{-- The link as text as well. Corporate mail gateways rewrite button links and
some of them fetch the target first to scan it on a single-use link that
burns the confirmation before the recipient ever sees it. --}}
<tr><td style="padding:28px 40px 0 40px;">
<tr><td style="padding:28px 24px 0 24px;">
<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;">
<p style="margin:0 0 6px 0;font-size:12px;line-height:16px;color:#6e6e7a;">{{ __('verify_email.fallback') }}</p>
@ -29,7 +29,7 @@
</table>
</td></tr>
<tr><td style="padding:24px 40px 32px 40px;">
<tr><td style="padding:24px 24px 32px 24px;">
<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;">{{ __('verify_email.not_you') }}</p>

View File

@ -45,6 +45,20 @@ Route::get('/finance', Admin\Finance::class)->name('finance');
Route::get('/customers/{uuid}', Admin\CustomerDetail::class)->name('customer');
// What customers wrote to us — see Admin\Inbox.
Route::get('/inbox', Admin\Inbox::class)->name('inbox');
// Looking at a mail before a customer does. The list is a page; the render is a
// plain response, because a mail is a whole document and belongs in its own tab.
Route::get('/mail/preview', Admin\MailPreview::class)->name('mail.preview');
Route::get('/mail/preview/{key}', function (string $key) {
abort_unless(auth('operator')->user()?->can('mail.manage') ?? false, 403);
$mailable = app(App\Services\Mail\MailPreviews::class)->make($key);
abort_if($mailable === null, 404);
// The rendered mail, as the recipient's client would get it. Not escaped
// into a console page: this is the document itself.
return response($mailable->render());
})->name('mail.preview.show');
// The answers an operator gives over and over, written once — see
// Admin\MailTemplates.
Route::get('/mail-templates', Admin\MailTemplates::class)->name('templates');

View File

@ -0,0 +1,130 @@
<?php
use App\Livewire\Admin\MailPreview;
use App\Models\Invoice;
use App\Models\MaintenanceWindow;
use App\Models\Order;
use App\Services\Mail\MailPreviews;
use Illuminate\Support\Facades\Mail;
use Livewire\Livewire;
/**
* Looking at a mail before a customer does.
*
* There was no way to: an invoice mail needs an invoice, a maintenance
* announcement needs a window, and "register an account to see whether the
* confirmation reads well on a phone" is not a workflow. Which is how every mail
* went out with a hard 600px table until somebody opened one on a telephone.
*/
it('renders every mail this installation sends', function () {
// The whole point of the list: a mail that cannot be rendered is one nobody
// finds out about until the event that sends it happens for real.
$previews = app(MailPreviews::class);
expect($previews->all())->not->toBeEmpty();
foreach (array_keys($previews->all()) as $key) {
$html = $previews->make($key)?->render();
expect($html)->toBeString()
->and(strlen((string) $html))->toBeGreaterThan(500);
}
});
it('leaves nothing behind in the database', function () {
// make(), never create(). A preview must not leave an invoice, an order or a
// maintenance window lying about — least of all one carrying a number drawn
// from a series.
$before = [
'invoices' => Invoice::query()->count(),
'orders' => Order::query()->count(),
'windows' => MaintenanceWindow::query()->count(),
];
foreach (array_keys(app(MailPreviews::class)->all()) as $key) {
app(MailPreviews::class)->make($key)?->render();
}
expect(Invoice::query()->count())->toBe($before['invoices'])
->and(Order::query()->count())->toBe($before['orders'])
->and(MaintenanceWindow::query()->count())->toBe($before['windows']);
});
it('answers nothing for a key nobody knows, rather than throwing', function () {
// Reached from a URL, and a URL is a string somebody can retype.
expect(app(MailPreviews::class)->make('gibt-es-nicht'))->toBeNull();
});
it('sends a preview to the operator own address and to nobody else', function () {
Mail::fake();
$operator = operator('Owner');
Livewire::actingAs($operator, 'operator')
->test(MailPreview::class)
->call('sendToMe', 'verify-email')
->assertDispatched('notify');
// SENT, not queued. Every mailable here implements ShouldQueue, so send()
// would have deferred to the queue — putting a failure in a worker's log
// while the button reported success to somebody who is waiting for the mail.
Mail::assertSent(App\Mail\VerifyEmailMail::class, fn ($mail) => $mail->hasTo($operator->email));
Mail::assertNotQueued(App\Mail\VerifyEmailMail::class);
});
it('has no recipient field, because that would be a form for mailing strangers', function () {
$component = Illuminate\Support\Facades\File::get(app_path('Livewire/Admin/MailPreview.php'));
// The address comes from the signed-in operator, never from the request.
expect($component)->toContain("Auth::guard('operator')->user()")
->and($component)->not->toContain('public string $to');
});
it('serves the rendered mail as the document itself', function () {
// Not escaped into a console page: a mail is a whole document with its own
// background, and judging it inside a frame judges the frame.
$this->actingAs(operator('Owner'), 'operator')
->get(route('admin.mail.preview.show', 'verify-email'))
->assertOk()
->assertSee('max-width:600px', false);
});
it('keeps the preview to operators who may manage mail', function () {
Livewire::actingAs(operator('Read-only'), 'operator')
->test(MailPreview::class)
->assertForbidden();
$this->actingAs(operator('Read-only'), 'operator')
->get(route('admin.mail.preview.show', 'verify-email'))
->assertForbidden();
});
it('answers 404 for a preview that does not exist', function () {
$this->actingAs(operator('Owner'), 'operator')
->get(route('admin.mail.preview.show', 'gibt-es-nicht'))
->assertNotFound();
});
// ---- What made this necessary ----
it('builds the mail layout fluid, so a phone does not scroll sideways', function () {
// width="600" plus width:600px forced every phone to scroll to read a
// sentence. A mail cannot rely on a media query — Outlook renders with Word
// and drops <style> blocks — so the layout has to shrink by itself.
$html = (string) app(MailPreviews::class)->make('verify-email')?->render();
expect($html)->toContain('width:100%;max-width:600px')
->and($html)->not->toContain('width="600"')
// No fixed width anywhere, and max-width does not count as one.
->and(preg_match('/style="[^"]*(?<!max-)width:600px/', $html))->toBe(0);
});
it('does not spend a third of a phone screen on side padding', function () {
// 40px each side leaves 278px of a 390px screen. 24px leaves 310px, and on
// the desktop 552px of a 600px canvas still reads well.
foreach (array_keys(app(MailPreviews::class)->all()) as $key) {
$html = (string) app(MailPreviews::class)->make($key)?->render();
expect(preg_match('/padding:[^";]*\b40px\b[^";]*40px/', $html))->toBe(0, $key);
}
});

View File

@ -45,7 +45,12 @@ it('shows the granted plan on the cloud dashboard without a price', function ()
Livewire::actingAs($user)
->test(Cloud::class)
->assertDontSee('179')
// The formatted amount, not the bare digits: "179" also matches an id,
// and ids climb through a suite run because a rolled-back transaction
// does not reset an auto-increment counter. That made this test pass
// alone and fail in company. The line below is what a leak would
// actually look like on the page.
->assertDontSee('179,00')
->assertDontSee('0 €')
->assertSee(__('billing.plan.team'));
});

View File

@ -318,3 +318,17 @@ it('still refuses the checkout when the box was not ticked', function () {
expect($this->stripe->checkouts)->toBeEmpty();
});
it('renders inside the app shell, not the bare sign-in one', function () {
// layouts.portal is what the sign-in pages use: no navigation, no page
// padding. In it, this page sat flush against the top and bottom of the
// window with no way back to anything.
expect(Illuminate\Support\Facades\File::get(app_path('Livewire/Order.php')))
->toContain("#[Layout('layouts.portal-app')]");
// And the shell is actually there: the navigation proves it.
$this->actingAs(buyer())
->get(route('order'))
->assertOk()
->assertSee(__('dashboard.nav.overview'));
});