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; + } +} diff --git a/app/Notifications/TransactionalEmails/MaintenanceNotice.php b/app/Notifications/TransactionalEmails/MaintenanceNotice.php new file mode 100644 index 0000000000..8bda2a5bd0 --- /dev/null +++ b/app/Notifications/TransactionalEmails/MaintenanceNotice.php @@ -0,0 +1,64 @@ +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', + 'subscriptionNewUrl' => 'https://app.coolify.io/subscription/new', + ]); + + 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..ca442ff472 --- /dev/null +++ b/resources/views/emails/maintenance-notice.blade.php @@ -0,0 +1,110 @@ + + + + + + + +
+ + + + + + + +
+

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) + Tomorrow we will perform a scheduled maintenance to migrate Coolify Cloud from v4 to v5. The maintenance window: + @else + In {{ $daysFromNow }} days we will perform a scheduled maintenance to migrate Coolify Cloud from v4 to v5. The maintenance window: + @endif +

+ + + + + + + + + +
+
START TIME
+
{{ $startUtcLong }}
+
+
END TIME
+
{{ $endUtcLong }}
+
+ Central European time: {{ $startEu }} to {{ $endEu }} {{ $euAbbr }} +
+ +

+ 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. +

+ +

How will this affect my service?

+ @if ($isInactive) +

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 +
    + @if ($hasInactiveTeams) +
  • One or more of your teams are inactive (no active subscription for 90+ days). Inactive teams will not be migrated to v5 and their data will be permanently deleted.
  • + @endif +
  • All your existing deployments, resources and servers will keep running as normal, so your public websites and services will continue to be fully accessible.
  • +
  • No deployments can be triggered during the maintenance window (this includes auto-deployments on commits).
  • +
  • No configuration changes can be made during the maintenance window.
  • +
  • You will be logged out and need to re-authenticate on your next visit.
  • +
+ @endif + +

Are there any required actions I need to take?

+ + @if ($isInactive) +

Yes. Resubscribe before the maintenance window to keep your account: resubscribe here.

+ @else + @if ($hasInactiveTeams) +

Before the maintenance window: Yes.

+ + @else +

Before the maintenance window: No.

+ + @endif + +

After the maintenance window: Yes (~10 minutes of work for most users):

+
    +
  1. Login again.
  2. +
  3. Upgrade all your servers from each server's detail page.
  4. +
  5. Verify each application and database is correctly configured.
  6. +
  7. Redeploy each application and database once to move them to the new deployment architecture. Auto-deployments on commits stay disabled until this step is completed.
  8. +
+

Full details in the v5 upgrade guide.

+ @endif + +

Can I reschedule or avoid this maintenance window?

+

+ No. This is a required migration to a more reliable, modern and improved Coolify architecture and applies to all Cloud customers. +

+ +

Questions?

+

+ Reach out to us in the migration channel on the coolLabs Discord or simply reply to this email. +

+ +

Thanks,
The Coolify Team

+
+

You're receiving this email because you have a Coolify Cloud account at coolify.io.

+
+
+ +