CluPilotCloud/app/Services/Mail/MailPreviews.php

292 lines
12 KiB
PHP

<?php
namespace App\Services\Mail;
use App\Console\Commands\PruneDormantAccounts;
use App\Mail\CloudResumedMail;
use App\Mail\CloudSuspendedMail;
use App\Mail\Concerns\SendsFromMailbox;
use App\Mail\DormantAccountWarningMail;
use App\Mail\DunningNoticeMail;
use App\Mail\InvoiceMail;
use App\Mail\MaintenanceAnnouncementMail;
use App\Mail\MaintenanceCancelledMail;
use App\Mail\NewDeviceSignInMail;
use App\Mail\OperatorInvitationMail;
use App\Mail\OperatorMessageMail;
use App\Mail\OrderConfirmationMail;
use App\Mail\ResetPasswordMail;
use App\Mail\SecurityBlockMail;
use App\Mail\VerifyEmailMail;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\Invoice;
use App\Models\MaintenanceWindow;
use App\Models\Operator;
use App\Models\Order;
use App\Models\SecurityBlock;
use App\Models\User;
use App\Models\UserDevice;
use App\Services\Billing\DunningSchedule;
use Illuminate\Mail\Mailable;
use Illuminate\Mail\Mailables\Content;
use Illuminate\Mail\Mailables\Envelope;
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.
*
* Read from MailCatalogue rather than kept as a second list — that WAS
* this list, before MailRoute needed a default purpose per mail type
* too. Two lists is the shape of bug that forgets the seventeenth mail.
*
* @return array<string, string> key => label
*/
public function all(): array
{
return array_map(fn (array $entry) => $entry['label'], MailCatalogue::all());
}
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),
'operator-invitation' => new OperatorInvitationMail(
$this->invitedOperator(),
'Ines Inhaber',
'https://example.test/admin/invitation/beispiel-token',
// Aus der Broker-Einstellung, nicht als Zahl daneben: eine
// Vorschau, die eine andere Frist nennt als die echte Mail,
// ist genau die Sorte Beleg, auf die sich niemand verlassen
// kann.
OperatorInvitationMail::validHours(),
),
'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),
'dunning-0', 'dunning-1', 'dunning-2', 'dunning-3' => new DunningNoticeMail(
name: $customer->name,
level: (int) substr($key, -1),
amountCents: 21480,
feeCents: DunningSchedule::feeCents((int) substr($key, -1)),
feeTotalCents: (int) substr($key, -1) >= 2 ? 500 : 0,
currency: 'EUR',
billingUrl: route('billing'),
suspendOn: now()->addDays(14)->local()->isoFormat('D. MMMM YYYY'),
),
'cloud-suspended' => new CloudSuspendedMail(
name: $customer->name, amountCents: 21480, feeTotalCents: 1500,
currency: 'EUR', billingUrl: route('billing'),
),
'cloud-resumed' => new CloudResumedMail(
name: $customer->name, amountCents: 0, feeTotalCents: 0,
currency: 'EUR', billingUrl: route('cloud'),
),
'security-block' => new SecurityBlockMail($this->securityBlock($customer)),
'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]);
}
/**
* Der eingeladene Betreiber. make(), nie create() — wie alles hier.
*
* Ein Betreiber und kein User: die Einladung geht in die Konsole, und die
* beiden Personengruppen teilen keine Identität (R21). Ein Beispiel, das
* die falsche Klasse benutzt, wäre eine Vorschau auf eine Mail, die es so
* nie gibt.
*/
private function invitedOperator(): Operator
{
return Operator::make(['name' => 'Tom Neu', 'email' => 'tom@clupilot.test'])
->forceFill(['id' => 0]);
}
private function order(Customer $customer): Order
{
return Order::make([
'plan' => 'start',
// 39,00 €: der Monatspreis von Start auf der neuen Leiter.
'amount_cents' => 3900,
'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(),
// Dieselben 39,00 € netto wie order() oben, plus 20 % USt.
'net_cents' => 3900,
'tax_cents' => 780,
'gross_cents' => 4680,
'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]);
}
/**
* Eine Sperre samt der Instanz, an der sie hängt — beide nur im Speicher
* (setRelation statt eines echten Fremdschlüssels), damit SecurityBlockMail
* bis zum Kunden durchgreifen kann, ohne dass eine dieser drei Zeilen in
* der Datenbank landet.
*/
private function securityBlock(Customer $customer): SecurityBlock
{
$instance = Instance::make(['subdomain' => 'nc-beispiel', 'status' => 'active'])
->forceFill(['id' => 0])
->setRelation('customer', $customer);
return SecurityBlock::make([
'ip' => '203.0.113.7',
'reason' => 'instance_login',
'attempts' => 12,
'strikes' => 1,
'blocked_at' => Carbon::now(),
'expires_at' => Carbon::now()->addHour(),
])->forceFill(['id' => 0])->setRelation('instance', $instance);
}
/**
* 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 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(MailCatalogue::mailer('cloud-ready'));
}
public function envelope(): Envelope
{
return $this->mailboxEnvelope(MailPurpose::PROVISIONING, __('provisioning.mail.ready_subject'), 'cloud-ready');
}
public function content(): 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 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;
}
}