CluPilotCloud/app/Services/Mail/MailPreviews.php

220 lines
8.4 KiB
PHP

<?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\Console\Commands\PruneDormantAccounts;
use App\Mail\DormantAccountWarningMail;
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',
'dormant-warning' => 'Konto ohne Paket wird gelöscht',
'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'),
'dormant-warning' => new DormantAccountWarningMail($user, PruneDormantAccounts::WARN_DAYS_BEFORE),
'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
{
use \App\Mail\Concerns\SendsFromMailbox;
public function __construct(public Customer $customer)
{
// The same mailbox the real notification sends from. Without
// this the preview took the framework default — an address no
// mailbox owns, over whichever account mail.default logs in
// with, which is the "553 Sender address rejected" every
// properly configured mail server answers with.
$this->mailer('cp_'.MailPurpose::PROVISIONING);
}
public function envelope(): \Illuminate\Mail\Mailables\Envelope
{
return $this->mailboxEnvelope(MailPurpose::PROVISIONING, __('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;
}
}