From e83b1d886a3fa563d73641165603256e458d321b Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 08:59:24 +0200 Subject: [PATCH] Eine Warnung, bevor die eigene Cloud den Kunden aussperrt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ab dem Laufzeitende zieht EndInstanceService die Adresse ein — der Kunde kommt ab dem Moment nicht mehr an seine Nextcloud. Wer selbst etwas herunterladen will, muss es VORHER tun, und das wusste bisher niemand. Die Auswahl ist bewusst "hoechstens sieben Tage" und nicht "genau sieben Tage": ein Gleichheitsvergleich verfehlt jede Instanz, die zwischen zwei Laeufen durchrutscht, und der Preis dafuer waere, dass jemand ausgesperrt wird, ohne es gewusst zu haben. Zusaetzlich zum Zettel: lang/{de,en}/mail_pace.php bekommen einen Eintrag fuer ServiceEndingSoonMail, weil MailPacePageTest fuer jede Klasse in MailLane::all() einen Anzeigenamen verlangt — ohne ihn waere die Suite rot. --- app/Console/Commands/RemindEndingServices.php | 109 ++++++++++++++++++ app/Mail/ServiceEndingSoonMail.php | 66 +++++++++++ app/Services/Mail/MailCatalogue.php | 5 + app/Services/Mail/MailLane.php | 4 + app/Services/Mail/MailPreviews.php | 17 +++ lang/de/mail_pace.php | 1 + lang/de/service_ending.php | 15 +++ lang/en/mail_pace.php | 1 + lang/en/service_ending.php | 12 ++ .../views/mail/service-ending-soon.blade.php | 37 ++++++ routes/console.php | 15 +++ .../Cancellation/RemindEndingServicesTest.php | 86 ++++++++++++++ 12 files changed, 368 insertions(+) create mode 100644 app/Console/Commands/RemindEndingServices.php create mode 100644 app/Mail/ServiceEndingSoonMail.php create mode 100644 lang/de/service_ending.php create mode 100644 lang/en/service_ending.php create mode 100644 resources/views/mail/service-ending-soon.blade.php create mode 100644 tests/Feature/Cancellation/RemindEndingServicesTest.php diff --git a/app/Console/Commands/RemindEndingServices.php b/app/Console/Commands/RemindEndingServices.php new file mode 100644 index 0000000..46ccae3 --- /dev/null +++ b/app/Console/Commands/RemindEndingServices.php @@ -0,0 +1,109 @@ +option('dry-run'); + $reminded = 0; + + $due = Instance::query() + ->with('customer') + ->where('status', 'cancellation_scheduled') + ->whereNotNull('service_ends_at') + // In der Zukunft und höchstens sieben Tage entfernt — siehe + // Kopfkommentar. Eine bereits abgelaufene Instanz gehört + // EndDueServices, nicht mehr dieser Erinnerung. + ->where('service_ends_at', '>', now()) + ->where('service_ends_at', '<=', now()->addDays(7)) + ->whereNull('export_reminded_at') + ->cursor(); + + foreach ($due as $instance) { + $customer = $instance->customer; + + if ($customer === null || blank($customer->email)) { + // Laut, aber ohne export_reminded_at zu setzen: eine Mail, die + // nie eine Adresse hatte, ist keine, die verschickt wurde. + Log::warning('Instanz nähert sich dem Laufzeitende, aber kein erreichbarer Kunde dahinter.', [ + 'instance' => $instance->uuid, + ]); + + continue; + } + + $this->line(($dryRun ? '[dry-run] ' : '') + ."{$instance->subdomain}: Laufzeit endet am " + .$instance->service_ends_at->local()->isoFormat('LL')); + + if ($dryRun) { + $reminded++; + + continue; + } + + try { + $mail = new ServiceEndingSoonMail($instance); + Mail::mailer($mail->mailer)->to($customer->email)->queue($mail); + } catch (Throwable $e) { + Log::warning('Die Erinnerung vor dem Laufzeitende ließ sich nicht einreihen.', [ + 'instance' => $instance->uuid, + 'exception' => $e->getMessage(), + ]); + + continue; + } + + // Hält fest, dass erinnert wurde — ohne diese Spalte schickte der + // tägliche Lauf dieselbe Mail an jedem der restlichen Tage erneut. + $instance->update(['export_reminded_at' => now()]); + $reminded++; + } + + $this->info($dryRun + ? "Probelauf: {$reminded} Erinnerung(en) würden verschickt. Nichts wurde geändert." + : "{$reminded} Erinnerung(en) verschickt."); + + return self::SUCCESS; + } +} diff --git a/app/Mail/ServiceEndingSoonMail.php b/app/Mail/ServiceEndingSoonMail.php new file mode 100644 index 0000000..4a37210 --- /dev/null +++ b/app/Mail/ServiceEndingSoonMail.php @@ -0,0 +1,66 @@ +mailer(MailCatalogue::mailer('service-ending-soon')); + } + + public function envelope(): Envelope + { + return $this->mailboxEnvelope( + MailPurpose::PROVISIONING, + __('service_ending.subject', ['date' => $this->instance->service_ends_at->local()->isoFormat('LL')]), + 'service-ending-soon', + ); + } + + public function content(): Content + { + return new Content(view: 'mail.service-ending-soon', with: [ + 'name' => (string) ($this->instance->customer?->name ?? ''), + // ->local() (R19): das Datum, das dem Kunden genannt wird, muss + // seine eigene Wanduhr zeigen, nicht die Speicherzone UTC. + 'date' => $this->instance->service_ends_at->local()->isoFormat('LL'), + // https:// davor, wie überall sonst, wo Instance::address() zu + // einer klickbaren Adresse wird (z. B. App\Livewire\ + // Dashboard::domain()) — die eine Stelle, die eigene Domain vor + // Subdomain entscheidet, statt das hier zu wiederholen. + 'url' => 'https://'.$this->instance->address(ProvisioningSettings::dnsZone()), + 'exportWish' => $this->instance->export_wish, + ]); + } +} diff --git a/app/Services/Mail/MailCatalogue.php b/app/Services/Mail/MailCatalogue.php index a2357e3..d586134 100644 --- a/app/Services/Mail/MailCatalogue.php +++ b/app/Services/Mail/MailCatalogue.php @@ -34,6 +34,11 @@ final class MailCatalogue 'cloud-ready' => ['label' => 'Cloud ist bereit (Zugangsdaten)', 'purpose' => MailPurpose::PROVISIONING], 'maintenance-announcement' => ['label' => 'Wartungsfenster angekündigt', 'purpose' => MailPurpose::MAINTENANCE], 'maintenance-cancelled' => ['label' => 'Wartungsfenster abgesagt', 'purpose' => MailPurpose::MAINTENANCE], + // Die Erinnerung vor dem Laufzeitende (App\Mail\ServiceEndingSoonMail). + // Kein Rundschreiben und keine Werbung, sondern eine Frist, auf die + // der Empfänger wartet — PROVISIONING wie cloud-ready, weil es um + // die Maschine selbst geht, nicht um Geld. + 'service-ending-soon' => ['label' => 'Cloud endet bald (Erinnerung)', 'purpose' => MailPurpose::PROVISIONING], 'invoice' => ['label' => 'Rechnung', 'purpose' => MailPurpose::BILLING], 'new-device' => ['label' => 'Anmeldung von einem neuen Gerät', 'purpose' => MailPurpose::SYSTEM], 'dormant-warning' => ['label' => 'Konto ohne Paket wird gelöscht', 'purpose' => MailPurpose::SYSTEM], diff --git a/app/Services/Mail/MailLane.php b/app/Services/Mail/MailLane.php index e46f99e..4879428 100644 --- a/app/Services/Mail/MailLane.php +++ b/app/Services/Mail/MailLane.php @@ -16,6 +16,7 @@ use App\Mail\OperatorMessageMail; use App\Mail\OrderConfirmationMail; use App\Mail\ResetPasswordMail; use App\Mail\SecurityBlockMail; +use App\Mail\ServiceEndingSoonMail; use App\Mail\VerifyEmailMail; use App\Support\Settings; use RuntimeException; @@ -70,6 +71,9 @@ final class MailLane MaintenanceCancelledMail::class => self::URGENT, CloudSuspendedMail::class => self::URGENT, CloudResumedMail::class => self::URGENT, + // Kein Rundschreiben und keine Werbung, sondern eine Frist, auf die + // der Empfänger wartet — wie die Wartungsankündigung daneben. + ServiceEndingSoonMail::class => self::URGENT, InvoiceMail::class => self::CALM, DunningNoticeMail::class => self::CALM, DormantAccountWarningMail::class => self::CALM, diff --git a/app/Services/Mail/MailPreviews.php b/app/Services/Mail/MailPreviews.php index 6d6fa67..6ade91e 100644 --- a/app/Services/Mail/MailPreviews.php +++ b/app/Services/Mail/MailPreviews.php @@ -17,6 +17,7 @@ use App\Mail\OperatorMessageMail; use App\Mail\OrderConfirmationMail; use App\Mail\ResetPasswordMail; use App\Mail\SecurityBlockMail; +use App\Mail\ServiceEndingSoonMail; use App\Mail\VerifyEmailMail; use App\Models\Customer; use App\Models\Instance; @@ -107,6 +108,7 @@ class MailPreviews 'cloud-ready' => $this->cloudReady($customer), 'maintenance-announcement' => new MaintenanceAnnouncementMail($this->window(), $customer), 'maintenance-cancelled' => new MaintenanceCancelledMail($this->window(), $customer), + 'service-ending-soon' => new ServiceEndingSoonMail($this->endingInstance($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), @@ -243,6 +245,21 @@ class MailPreviews ])->forceFill(['id' => 0])->setRelation('instance', $instance); } + /** + * Eine gekündigte Instanz sechs Tage vor dem Laufzeitende, mit + * gewünschtem Export — die Kombination, die den zusätzlichen Absatz + * zeigt. make(), nie create(), wie alles hier. + */ + private function endingInstance(Customer $customer): Instance + { + return Instance::make([ + 'subdomain' => 'nc-beispiel', + 'status' => 'cancellation_scheduled', + 'service_ends_at' => Carbon::now()->addDays(6), + 'export_wish' => true, + ])->forceFill(['id' => 0])->setRelation('customer', $customer); + } + /** * The credentials mail is a notification rather than a Mailable. * diff --git a/lang/de/mail_pace.php b/lang/de/mail_pace.php index 4614f34..b288bcb 100644 --- a/lang/de/mail_pace.php +++ b/lang/de/mail_pace.php @@ -58,6 +58,7 @@ return [ 'MaintenanceCancelledMail' => 'Wartungsfenster abgesagt', 'CloudSuspendedMail' => 'Cloud abgeschaltet', 'CloudResumedMail' => 'Cloud läuft wieder', + 'ServiceEndingSoonMail' => 'Cloud endet bald (Erinnerung)', 'InvoiceMail' => 'Rechnung', 'DunningNoticeMail' => 'Zahlungserinnerungen und Mahnungen', 'DormantAccountWarningMail' => 'Konto ohne Paket wird gelöscht', diff --git a/lang/de/service_ending.php b/lang/de/service_ending.php new file mode 100644 index 0000000..b02d9be --- /dev/null +++ b/lang/de/service_ending.php @@ -0,0 +1,15 @@ + 'Ihre Cloud endet am :date', + 'heading' => 'Bald ist Schluss', + 'preheader' => 'Sichern Sie sich, was Sie brauchen — ab :date kommen Sie nicht mehr hinein.', + 'greeting' => 'Guten Tag :name,', + 'intro' => 'Ihr Vertrag endet am :date. Ab diesem Zeitpunkt ziehen wir die Adresse Ihrer Cloud ein — Sie kommen dann nicht mehr an Ihre eigenen Dateien.', + 'how_to_download' => 'Melden Sie sich bis dahin selbst in Ihrer Cloud an und laden Sie herunter, was Sie behalten möchten.', + 'action' => 'Zur eigenen Cloud', + 'export_prepared' => 'Da Sie beim Kündigen einen Export gewünscht haben, bereiten wir zusätzlich einen für Sie vor — unabhängig davon lohnt es sich, vorher selbst zu sichern.', +]; diff --git a/lang/en/mail_pace.php b/lang/en/mail_pace.php index 3f3eec8..15cd5eb 100644 --- a/lang/en/mail_pace.php +++ b/lang/en/mail_pace.php @@ -52,6 +52,7 @@ return [ 'MaintenanceCancelledMail' => 'Maintenance window cancelled', 'CloudSuspendedMail' => 'Cloud suspended', 'CloudResumedMail' => 'Cloud running again', + 'ServiceEndingSoonMail' => 'Cloud ending soon (reminder)', 'InvoiceMail' => 'Invoice', 'DunningNoticeMail' => 'Payment reminders and dunning notices', 'DormantAccountWarningMail' => 'Account without a plan will be deleted', diff --git a/lang/en/service_ending.php b/lang/en/service_ending.php new file mode 100644 index 0000000..5a4cffb --- /dev/null +++ b/lang/en/service_ending.php @@ -0,0 +1,12 @@ + 'Your cloud ends on :date', + 'heading' => 'Time is running out', + 'preheader' => 'Save what you need — from :date you can no longer get in.', + 'greeting' => 'Hello :name,', + 'intro' => 'Your contract ends on :date. From that point we withdraw the address of your cloud — you will no longer be able to reach your own files.', + 'how_to_download' => 'Sign in to your cloud yourself before then and download whatever you want to keep.', + 'action' => 'Go to my cloud', + 'export_prepared' => 'Since you asked for an export when you cancelled, we are additionally preparing one for you — either way, it is worth saving your own copy first.', +]; diff --git a/resources/views/mail/service-ending-soon.blade.php b/resources/views/mail/service-ending-soon.blade.php new file mode 100644 index 0000000..c019e94 --- /dev/null +++ b/resources/views/mail/service-ending-soon.blade.php @@ -0,0 +1,37 @@ + + + +

{{ __('service_ending.intro', ['date' => $date]) }}

+ + +{{-- Das eine Ziel dieser Mail: bis wann, und wie komme ich vorher an meine + eigenen Dateien. Ein Knopf statt einer Adresse im Fliesstext, weil das + hier keine Nebeninformation ist, sondern der Grund, warum die Mail + verschickt wird. --}} + +

{{ __('service_ending.how_to_download') }}

+ + +
+ {{ __('service_ending.action') }} +
+ + +{{-- Nur, wer beim Kündigen "ja" gesagt hat. Ein "nein" bedeutet ausdrücklich + "bereitet nichts vor" (ConfirmCancelPackage) — ihm hier trotzdem einen + Export anzukündigen wäre ein Versprechen, das niemand eingelöst hätte. --}} +@if ($exportWish === true) + + + +
+

{{ __('service_ending.export_prepared') }}

+
+ +@endif + +
diff --git a/routes/console.php b/routes/console.php index 277ea06..6c5ce0f 100644 --- a/routes/console.php +++ b/routes/console.php @@ -251,6 +251,21 @@ Schedule::command('clupilot:end-due-services') ->hourly() ->withoutOverlapping(); +// Die Erinnerung, bevor der Lauf oben die Adresse einzieht. +// +// Ab dem Laufzeitende kommt der Kunde selbst nicht mehr an seine Nextcloud — +// wer eigene Dateien will, muss es VORHER tun, und bislang erfuhr er das +// nirgends. RemindEndingServices sagt es ihm einmalig, bis zu sieben Tage +// vorher. +// +// Täglich statt stündlich: die Erinnerung hat einen Tag Spielraum, und eine +// Mail, die um drei Uhr nachts kommt, liest niemand anders als eine um neun — +// anders als beim Mahnlauf oben verspricht hier niemand eine Geschäftsstunde, +// nur einen Termin mit einer Woche Vorlauf. +Schedule::command('clupilot:remind-ending-services') + ->dailyAt('03:50') + ->withoutOverlapping(); + // Zertifikate: welche Namen bedient diese Installation, und wie lange gelten // sie noch. // diff --git a/tests/Feature/Cancellation/RemindEndingServicesTest.php b/tests/Feature/Cancellation/RemindEndingServicesTest.php new file mode 100644 index 0000000..9890835 --- /dev/null +++ b/tests/Feature/Cancellation/RemindEndingServicesTest.php @@ -0,0 +1,86 @@ +create([ + 'status' => 'cancellation_scheduled', + 'service_ends_at' => now()->addDays(6), + ]); + + $this->artisan('clupilot:remind-ending-services')->assertSuccessful(); + + Mail::assertQueued(ServiceEndingSoonMail::class); + expect($instance->fresh()->export_reminded_at)->not->toBeNull(); +}); + +it('erinnert nicht zweimal', function () { + // Ein täglicher Zeitplan würde die Erinnerung sonst an jedem der + // restlichen Tage erneut schicken. Eine Erinnerung im Sechserpack liest + // sich als Mahnung. + Mail::fake(); + Instance::factory()->create([ + 'status' => 'cancellation_scheduled', + 'service_ends_at' => now()->addDays(6), + 'export_reminded_at' => now()->subHour(), + ]); + + $this->artisan('clupilot:remind-ending-services')->assertSuccessful(); + + Mail::assertNothingQueued(); +}); + +it('erinnert nicht, wenn das Ende noch weit weg ist', function () { + Mail::fake(); + Instance::factory()->create([ + 'status' => 'cancellation_scheduled', + 'service_ends_at' => now()->addDays(30), + ]); + + $this->artisan('clupilot:remind-ending-services')->assertSuccessful(); + + Mail::assertNothingQueued(); +}); + +it('erinnert eine Instanz, die gar nicht gekündigt wurde, nicht', function () { + Mail::fake(); + Instance::factory()->create(['status' => 'active', 'service_ends_at' => now()->addDays(6)]); + + $this->artisan('clupilot:remind-ending-services')->assertSuccessful(); + + Mail::assertNothingQueued(); +}); + +it('erinnert auch dann, wenn das Ende schon in wenigen Stunden ist', function () { + // Der wichtigste Fall und der, den ein "genau sieben Tage"-Vergleich + // verfehlt: eine Instanz, die zwischen zwei Läufen durchgerutscht ist, + // oder eine, die mit kurzer Frist gekündigt wurde. Lieber spät erinnern + // als gar nicht — ab dem Laufzeitende kommt der Kunde nicht mehr hinein. + Mail::fake(); + Instance::factory()->create([ + 'status' => 'cancellation_scheduled', + 'service_ends_at' => now()->addHours(3), + ]); + + $this->artisan('clupilot:remind-ending-services')->assertSuccessful(); + + Mail::assertQueued(ServiceEndingSoonMail::class); +}); + +it('trägt in der Mail das Datum und den Weg zum Selbst-Herunterladen', function () { + $instance = Instance::factory()->create([ + 'status' => 'cancellation_scheduled', + 'service_ends_at' => now()->addDays(6), + 'export_wish' => false, + ]); + + $text = (new ServiceEndingSoonMail($instance))->render(); + + // Kein roher Schlüssel, und die zwei Dinge, um die es geht: ab wann ist + // zu, und wie komme ich vorher an meine Sachen. + expect($text)->not->toContain('service_ending.') + ->and($text)->toContain($instance->service_ends_at->local()->isoFormat('LL')); +});