diff --git a/app/Livewire/Admin/MailPreview.php b/app/Livewire/Admin/MailPreview.php new file mode 100644 index 0000000..2d42188 --- /dev/null +++ b/app/Livewire/Admin/MailPreview.php @@ -0,0 +1,82 @@ +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), + ]); + } +} diff --git a/app/Livewire/Order.php b/app/Livewire/Order.php index d8f309d..7f038ac 100644 --- a/app/Livewire/Order.php +++ b/app/Livewire/Order.php @@ -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() diff --git a/app/Services/Mail/MailPreviews.php b/app/Services/Mail/MailPreviews.php new file mode 100644 index 0000000..32b420d --- /dev/null +++ b/app/Services/Mail/MailPreviews.php @@ -0,0 +1,205 @@ + 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; + } +} diff --git a/app/Support/Navigation.php b/app/Support/Navigation.php index 491cfdf..d77f9b2 100644 --- a/app/Support/Navigation.php +++ b/app/Support/Navigation.php @@ -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 diff --git a/lang/de/admin.php b/lang/de/admin.php index 4fb00d9..e6fd38b 100644 --- a/lang/de/admin.php +++ b/lang/de/admin.php @@ -11,6 +11,7 @@ return [ ], 'nav' => [ + 'mail_preview' => 'E-Mail-Vorschau', 'inbox' => 'Posteingang', 'mail_log' => 'Postausgang', 'templates' => 'E-Mail-Vorlagen', diff --git a/lang/de/checkout.php b/lang/de/checkout.php index d5306cf..663ced7 100644 --- a/lang/de/checkout.php +++ b/lang/de/checkout.php @@ -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.', ]; diff --git a/lang/de/mail_preview.php b/lang/de/mail_preview.php new file mode 100644 index 0000000..5a7633b --- /dev/null +++ b/lang/de/mail_preview.php @@ -0,0 +1,12 @@ + '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.', +]; diff --git a/lang/de/order.php b/lang/de/order.php index 9b1650d..5a5572a 100644 --- a/lang/de/order.php +++ b/lang/de/order.php @@ -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', diff --git a/lang/en/admin.php b/lang/en/admin.php index b6fbe70..e008984 100644 --- a/lang/en/admin.php +++ b/lang/en/admin.php @@ -11,6 +11,7 @@ return [ ], 'nav' => [ + 'mail_preview' => 'Mail preview', 'inbox' => 'Inbox', 'mail_log' => 'Mail sent', 'templates' => 'Mail templates', diff --git a/lang/en/checkout.php b/lang/en/checkout.php index 146113e..9d8ecb4 100644 --- a/lang/en/checkout.php +++ b/lang/en/checkout.php @@ -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.', ]; diff --git a/lang/en/mail_preview.php b/lang/en/mail_preview.php new file mode 100644 index 0000000..36f4667 --- /dev/null +++ b/lang/en/mail_preview.php @@ -0,0 +1,12 @@ + '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.', +]; diff --git a/lang/en/order.php b/lang/en/order.php index a480d2d..0964e64 100644 --- a/lang/en/order.php +++ b/lang/en/order.php @@ -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', diff --git a/resources/views/components/mail/layout.blade.php b/resources/views/components/mail/layout.blade.php index 3420f0e..5597d80 100644 --- a/resources/views/components/mail/layout.blade.php +++ b/resources/views/components/mail/layout.blade.php @@ -28,7 +28,12 @@
|
- |