220 lines
8.4 KiB
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;
|
|
}
|
|
}
|