From bb283f2fbce4f056b0c4328bd2590010aa627e89 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:02:46 +0200 Subject: [PATCH 1/4] feat(cloud): add v5 maintenance email command --- .../Commands/Cloud/SendMaintenanceEmail.php | 173 ++++++++++++++++++ 1 file changed, 173 insertions(+) create mode 100644 app/Console/Commands/Cloud/SendMaintenanceEmail.php diff --git a/app/Console/Commands/Cloud/SendMaintenanceEmail.php b/app/Console/Commands/Cloud/SendMaintenanceEmail.php new file mode 100644 index 0000000000..238b608b55 --- /dev/null +++ b/app/Console/Commands/Cloud/SendMaintenanceEmail.php @@ -0,0 +1,173 @@ +error('This command can only be run on Coolify Cloud.'); + + return 1; + } + + $maintenanceAtInput = $this->option('maintenance-at'); + if (! $maintenanceAtInput) { + $this->error('--maintenance-at is required, e.g. --maintenance-at="2026-04-29 22:00".'); + + return 1; + } + + try { + $maintenanceAt = Carbon::parse($maintenanceAtInput, 'Europe/Budapest')->setTimezone('UTC'); + } catch (\Throwable $e) { + $this->error('Could not parse --maintenance-at: '.$e->getMessage()); + + return 1; + } + + if ($testTo = $this->option('test-to')) { + return $this->sendTestEmails($testTo, $maintenanceAt); + } + + $throttlePerMinute = (int) $this->option('throttle-per-minute'); + if ($throttlePerMinute < 1) { + $this->error('--throttle-per-minute must be at least 1.'); + + return 1; + } + + $dryRun = (bool) $this->option('dry-run'); + + $totalUsers = User::query()->where('id', '!=', 0)->whereNotNull('email')->count(); + $estimatedMinutes = (int) ceil($totalUsers / $throttlePerMinute); + + $this->info('Coolify Cloud — v5 maintenance notification'); + $this->line(' Maintenance window : '.$maintenanceAt->copy()->setTimezone('Europe/Budapest')->format('Y-m-d H:i').' CET/CEST ('.$maintenanceAt->format('Y-m-d H:i').' UTC)'); + $this->line(' Recipients : '.$totalUsers); + $this->line(' Throttle : '.$throttlePerMinute.'/min (~'.$estimatedMinutes.' min to drain queue)'); + if ($dryRun) { + $this->warn(' Dry run : YES (no emails will be sent)'); + } + $this->newLine(); + + if (! $this->confirm('Have you run `cloud:mark-inactive` recently? Inactive flags must be up to date before sending.', false)) { + $this->warn('Aborted. Run `php artisan cloud:mark-inactive` first, then re-run this command.'); + + return 1; + } + + if (! $this->confirm('Send maintenance notification to all '.$totalUsers.' users now?', false)) { + $this->warn('Aborted.'); + + return 1; + } + $this->newLine(); + + $stats = ['active_sent' => 0, 'inactive_sent' => 0, 'failed' => 0, 'skipped' => 0]; + $dispatched = 0; + $startedAt = now(); + + User::query() + ->select(['id', 'email', 'is_inactive']) + ->where('id', '!=', 0) + ->whereNotNull('email') + ->orderBy('id') + ->chunkById(500, function ($users) use ($maintenanceAt, $dryRun, $startedAt, $throttlePerMinute, &$stats, &$dispatched) { + foreach ($users as $user) { + if (! filter_var($user->email, FILTER_VALIDATE_EMAIL)) { + $stats['skipped']++; + + continue; + } + + $isInactive = (bool) $user->is_inactive; + $line = sprintf(' [%s] %s', $isInactive ? 'INACTIVE' : 'ACTIVE ', $user->email); + + if ($dryRun) { + $this->line($line.' (dry-run)'); + $isInactive ? $stats['inactive_sent']++ : $stats['active_sent']++; + + continue; + } + + try { + $delayUntil = $startedAt->copy()->addSeconds(intdiv($dispatched * 60, $throttlePerMinute)); + $user->notify((new MaintenanceNotice($user, $maintenanceAt))->delay($delayUntil)); + $isInactive ? $stats['inactive_sent']++ : $stats['active_sent']++; + $dispatched++; + $this->line($line.' (delayed until '.$delayUntil->format('H:i:s').' UTC)'); + } catch (\Throwable $e) { + $stats['failed']++; + $this->warn($line.' FAILED: '.$e->getMessage()); + } + } + }); + + $this->newLine(); + $this->table( + ['Metric', 'Count'], + collect($stats)->map(fn ($v, $k) => [$k, $v])->values()->all(), + ); + + if (! $dryRun) { + $finishesAt = $startedAt->copy()->addSeconds(intdiv($dispatched * 60, $throttlePerMinute)); + $this->newLine(); + $this->info('All '.$dispatched.' notifications queued. Last email scheduled to send around '.$finishesAt->format('Y-m-d H:i:s').' UTC.'); + } + + return 0; + } + + private function sendTestEmails(string $email, Carbon $maintenanceAt): int + { + if (! filter_var($email, FILTER_VALIDATE_EMAIL)) { + $this->error('Invalid email address: '.$email); + + return 1; + } + + $this->info('Sending TEST maintenance emails (active, inactive, active+inactive-team variants)'); + $this->line(' To : '.$email); + $this->line(' Maintenance window : '.$maintenanceAt->copy()->setTimezone('Europe/Budapest')->format('Y-m-d H:i').' CET/CEST ('.$maintenanceAt->format('Y-m-d H:i').' UTC)'); + $this->newLine(); + + $variants = [ + ['label' => 'ACTIVE ', 'inactive' => false, 'hasInactiveTeams' => false], + ['label' => 'INACTIVE ', 'inactive' => true, 'hasInactiveTeams' => false], + ['label' => 'ACTIVE + INACTIVE TEAM ', 'inactive' => false, 'hasInactiveTeams' => true], + ]; + + $exitCode = 0; + foreach ($variants as $variant) { + $user = new User; + $user->name = 'Test Recipient'; + $user->email = $email; + $user->is_inactive = $variant['inactive']; + + try { + $user->notifyNow(new MaintenanceNotice($user, $maintenanceAt, hasInactiveTeamsOverride: $variant['hasInactiveTeams'])); + $this->line(' ['.$variant['label'].'] sent'); + } catch (\Throwable $e) { + $this->warn(' ['.$variant['label'].'] FAILED: '.$e->getMessage()); + $exitCode = 1; + } + } + + return $exitCode; + } +} From fcc3615f038ea1deeff9764f9cd3a574377c6bb9 Mon Sep 17 00:00:00 2001 From: peaklabs-dev <122374094+peaklabs-dev@users.noreply.github.com> Date: Wed, 29 Apr 2026 16:10:41 +0200 Subject: [PATCH 2/4] feat(cloud): add v5 maintenance notification email --- .../TransactionalEmails/MaintenanceNotice.php | 63 ++++++++++ .../views/emails/maintenance-notice.blade.php | 109 ++++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 app/Notifications/TransactionalEmails/MaintenanceNotice.php create mode 100644 resources/views/emails/maintenance-notice.blade.php diff --git a/app/Notifications/TransactionalEmails/MaintenanceNotice.php b/app/Notifications/TransactionalEmails/MaintenanceNotice.php new file mode 100644 index 0000000000..8fb2567fc8 --- /dev/null +++ b/app/Notifications/TransactionalEmails/MaintenanceNotice.php @@ -0,0 +1,63 @@ +onQueue('high'); + } + + public function via(): array + { + return [TransactionalEmailChannel::class]; + } + + public function toMail(): MailMessage + { + $startUtc = $this->maintenanceAt->copy()->utc(); + $endUtc = $startUtc->copy()->addMinutes($this->durationMinutes); + $startEu = $startUtc->copy()->setTimezone('Europe/Budapest'); + $endEu = $endUtc->copy()->setTimezone('Europe/Budapest'); + $daysFromNow = (int) round(now()->diffInDays($startUtc, false)); + + $hasInactiveTeams = $this->hasInactiveTeamsOverride + ?? (! $this->user->is_inactive + && $this->user->teams()->where('teams.is_inactive', true)->exists()); + + $mail = new MailMessage; + $mail->subject('Scheduled Maintenance: Coolify Cloud Infrastructure'); + $mail->view('emails.maintenance-notice', [ + 'isInactive' => (bool) $this->user->is_inactive, + 'hasInactiveTeams' => $hasInactiveTeams, + 'daysFromNow' => $daysFromNow, + 'startUtcLong' => $startUtc->format('F j, Y H:i').' UTC', + 'endUtcLong' => $endUtc->format('F j, Y H:i').' UTC', + 'startEu' => $startEu->format('H:i'), + 'endEu' => $endEu->format('H:i'), + 'euAbbr' => $startEu->format('T'), + 'durationMinutes' => $this->durationMinutes, + 'subscriptionUrl' => 'https://app.coolify.io/subscription', + ]); + + return $mail; + } +} diff --git a/resources/views/emails/maintenance-notice.blade.php b/resources/views/emails/maintenance-notice.blade.php new file mode 100644 index 0000000000..d7d7107456 --- /dev/null +++ b/resources/views/emails/maintenance-notice.blade.php @@ -0,0 +1,109 @@ + + +
+
+
|
+ ||||||
Dear Coolify Cloud Customer,
-+
@if ($daysFromNow <= 0) Today we will perform a scheduled maintenance to migrate Coolify Cloud from v4 to v5. The maintenance window: @elseif ($daysFromNow === 1) @@ -32,25 +32,26 @@
- During the {{ $durationMinutes }}-minute window (it may complete sooner), the Coolify Cloud dashboard at app.coolify.io will be offline and inaccessible. +
+ During the {{ $durationMinutes }}-minute window (it may complete sooner), the Coolify Cloud dashboard at app.coolify.io and the API at app.coolify.io/api will be offline and inaccessible.
Your account is inactive (no active subscription for 90+ days). Unless you resubscribe before the maintenance window, your account will not be migrated to v5 and your data will be permanently deleted.
+Your account is inactive (no active subscription for 90+ days). Unless you resubscribe before the maintenance window, your account will not be migrated to v5 and your data will be permanently deleted.
@else -Yes. Resubscribe before the maintenance window to keep your account: {{ $subscriptionUrl }}
+Yes. Resubscribe before the maintenance window to keep your account: resubscribe here.
@else @if ($hasInactiveTeams) -Before the maintenance window: Yes.
-Before the maintenance window: Yes.
+Before the maintenance window: No.
-Before the maintenance window: No.
+After the maintenance window: Yes (~10 minutes of work for most users):
-After the maintenance window: Yes (~10 minutes of work for most users):
+Full details in the v5 upgrade guide.
+Full details in the v5 upgrade guide.
@endif+
No. This is a required migration to a more reliable, modern and improved Coolify architecture and applies to all Cloud customers.
- Reach out to us in the #migration channel on Discord or simply reply to this email. +
+ Reach out to us in the migration channel on the coolLabs Discord or simply reply to this email.
-Thanks,
The Coolify Team
Thanks,
The Coolify Team
You're receiving this email because you have a Coolify Cloud account at coolify.io.
+You're receiving this email because you have a Coolify Cloud account at coolify.io.
After the maintenance window: Yes (~10 minutes of work for most users):
-Full details in the v5 upgrade guide.
@endif