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):
+
+ - Login again.
+ - Upgrade all your servers from each server's detail page.
+ - Verify each application and database is correctly configured.
+ - 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.
+
+ 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.
+ |
+
+
+ |
+
+
+
+