feat(platform): support SMTP EHLO, page-preserving teams, and upgrades

This commit is contained in:
Andras Bacsai 2026-08-19 11:41:41 +02:00
parent 849ba8f34c
commit 22f0af8d0e
10 changed files with 120 additions and 15 deletions

View file

@ -11,6 +11,7 @@
use App\Models\TelegramNotificationSettings;
use App\Models\WebhookNotificationSettings;
use App\Rules\SafeWebhookUrl;
use App\Rules\ValidHostname;
use Illuminate\Database\Eloquent\Model;
use Illuminate\Http\JsonResponse;
use Illuminate\Http\Request;
@ -37,6 +38,7 @@ private function channelConfig(string $channel): array
'smtp_username' => 'sometimes|nullable|string|max:255',
'smtp_password' => 'sometimes|nullable|string|max:255',
'smtp_timeout' => 'sometimes|nullable|integer|min:0',
'smtp_ehlo_domain' => ['sometimes', 'nullable', 'string', 'max:255', new ValidHostname],
'resend_enabled' => 'sometimes|boolean',
'resend_api_key' => 'sometimes|nullable|string|max:255',
'use_instance_email_settings' => 'sometimes|boolean',
@ -283,7 +285,7 @@ private function updateChannel(Request $request, string $channel): JsonResponse
#[OA\Get(
summary: 'Get email notification settings',
description: 'Get the current team email notification settings. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.',
description: 'Get the current team email notification settings, including `smtp_ehlo_domain`, the hostname sent with SMTP EHLO. Encrypted secrets are only returned when the token has `read:sensitive` (or `root`) and the user is a team admin/owner.',
path: '/notifications/email',
operationId: 'get-current-team-email-notifications',
security: [['bearerAuth' => []]],
@ -301,7 +303,7 @@ public function email(Request $request): JsonResponse
#[OA\Patch(
summary: 'Update email notification settings',
description: 'Update the current team email notification settings.',
description: 'Update the current team email notification settings. Set `smtp_ehlo_domain` to a valid hostname to control the SMTP EHLO domain, or `null` to use the system default.',
path: '/notifications/email',
operationId: 'update-current-team-email-notifications',
security: [['bearerAuth' => []]],

View file

@ -19,7 +19,7 @@ public function updatedSelectedTeamId()
$this->switch_to($this->selectedTeamId);
}
public function switch_to($team_id)
public function switch_to($team_id, ?string $currentUrl = null)
{
if (! auth()->user()->teams->contains($team_id)) {
return;
@ -30,6 +30,12 @@ public function switch_to($team_id)
}
refreshSession($team_to_switch_to);
return redirect('dashboard');
$parsedUrl = parse_url($currentUrl ?? '/dashboard');
$redirectUrl = data_get($parsedUrl, 'path', '/dashboard');
if ($query = data_get($parsedUrl, 'query')) {
$redirectUrl .= '?'.$query;
}
return redirect($redirectUrl);
}
}

View file

@ -20,6 +20,8 @@ class Upgrade extends Component
public bool $devMode = false;
public bool $fullButton = false;
protected $listeners = ['updateAvailable' => 'checkUpdate'];
public function mount()

View file

@ -124,6 +124,9 @@ class="flex size-8 shrink-0 items-center justify-center rounded-lg bg-neutral-10
<div id="server-timing-hud-slot-mobile" data-server-timing-hud-slot
class="hidden shrink-0 items-center"></div>
<div id="configuration-warning-hud-slot-mobile" class="relative shrink-0"></div>
@if (isInstanceAdmin() && !isCloud())
<livewire:upgrade key="mobile-upgrade" />
@endif
<x-top-user-menu />
<button type="button" class="-m-1 p-2 text-neutral-500 dark:text-fg-dim" x-on:click="open = !open">
<span class="sr-only">Open sidebar</span>

View file

@ -8,6 +8,11 @@
{{-- Exclude is_auto_update_enabled (instantSave) so the bar does not flash. --}}
<x-unsaved-bar action="submit" targets="update_check_frequency,auto_update_frequency" />
<x-application.settings-section title="Update Coolify"
helper="Install the latest Coolify version manually when an update is available.">
<livewire:upgrade :full-button="true" key="settings-upgrade" />
</x-application.settings-section>
<x-application.settings-section title="Update checks">
<x-slot:actions>
<x-forms.button type="button" wire:click="checkManually">

View file

@ -22,7 +22,7 @@ class="px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-neutral-
Teams
</div>
@foreach (auth()->user()->teams as $team)
<button type="button" wire:click="switch_to({{ $team->id }})" @click="open = false"
<button type="button" wire:click="switch_to({{ $team->id }}, window.location.href)" @click="open = false"
class="listbox-option {{ $team->id === $currentTeam->id ? 'bg-neutral-100 font-medium dark:bg-white/[0.06]' : '' }}">
<span class="min-w-0 flex-1 truncate">{{ $team->name }}</span>
</button>
@ -66,7 +66,7 @@ class="px-2 py-1 text-[10px] font-semibold uppercase tracking-wide text-neutral-
Teams
</div>
@foreach (auth()->user()->teams as $team)
<button type="button" wire:click="switch_to({{ $team->id }})" @click="teamOpen = false"
<button type="button" wire:click="switch_to({{ $team->id }}, window.location.href)" @click="teamOpen = false"
class="listbox-option {{ $team->id === $currentTeam->id ? 'bg-neutral-100 font-medium dark:bg-white/[0.06]' : '' }}">
<span class="min-w-0 flex-1 truncate">{{ $team->name }}</span>
</button>

View file

@ -6,6 +6,14 @@
})">
@if ($isUpgradeAvailable)
<div :class="{ 'z-40': modalOpen }" class="relative w-auto h-auto">
@if ($fullButton)
<x-forms.button type="button" @click="modalOpen=true" x-show="!showProgress" x-cloak isHighlighted>
Upgrade now
</x-forms.button>
<x-forms.button type="button" @click="modalOpen=true" x-show="showProgress" x-cloak isHighlighted>
Updating…
</x-forms.button>
@else
<button type="button" title="Upgrade in progress" aria-label="Upgrade in progress"
@click="modalOpen=true" x-show="showProgress" x-cloak
class="inline-flex h-[18px] cursor-pointer items-center rounded-full bg-coollabs/10 px-1.5 text-[9.5px] font-semibold leading-none text-coollabs ring-1 ring-inset ring-coollabs/25 transition-colors hover:bg-coollabs/15 dark:bg-warning/15 dark:text-warning dark:ring-warning/25 dark:hover:bg-warning/20">
@ -16,6 +24,7 @@ class="inline-flex h-[18px] cursor-pointer items-center rounded-full bg-coollabs
class="inline-flex h-[18px] cursor-pointer items-center rounded-full bg-coollabs/10 px-1.5 text-[9.5px] font-semibold leading-none text-coollabs ring-1 ring-inset ring-coollabs/25 transition-colors hover:bg-coollabs/15 dark:bg-warning/15 dark:text-warning dark:ring-warning/25 dark:hover:bg-warning/20">
Update available
</button>
@endif
<template x-teleport="body">
<div x-show="modalOpen"
class="fixed inset-0 z-99 flex min-h-full items-center justify-center overflow-y-auto p-4"
@ -162,6 +171,8 @@ class="flex flex-wrap items-center justify-end gap-2 border-t border-neutral-200
</div>
</template>
</div>
@elseif ($fullButton)
<p class="text-sm text-neutral-600 dark:text-fg-dim">Coolify is up to date.</p>
@endif
</div>

View file

@ -59,6 +59,7 @@ function authHeaders(string $token): array
$response->assertJsonStructure([
'team_id',
'smtp_enabled',
'smtp_ehlo_domain',
'deployment_failure_email_notifications',
'use_instance_email_settings',
]);
@ -171,6 +172,7 @@ function authHeaders(string $token): array
'smtp_enabled' => true,
'smtp_from_address' => 'alerts@example.com',
'smtp_host' => 'smtp.example.com',
'smtp_ehlo_domain' => 'coolify.example.com',
'smtp_port' => 587,
'smtp_encryption' => 'starttls',
'deployment_failure_email_notifications' => false,
@ -178,16 +180,42 @@ function authHeaders(string $token): array
$response->assertSuccessful();
$response->assertJsonPath('smtp_enabled', true);
$response->assertJsonPath('smtp_ehlo_domain', 'coolify.example.com');
$response->assertJsonPath('deployment_failure_email_notifications', false);
$settings = EmailNotificationSettings::query()->where('team_id', $this->team->id)->first();
expect($settings->smtp_enabled)->toBeTrue()
->and($settings->smtp_from_address)->toBe('alerts@example.com')
->and($settings->smtp_host)->toBe('smtp.example.com')
->and($settings->smtp_ehlo_domain)->toBe('coolify.example.com')
->and($settings->smtp_port)->toBe(587)
->and($settings->deployment_failure_email_notifications)->toBeFalse();
});
test('validates the smtp ehlo domain', function () {
$this->withHeaders(authHeaders($this->bearerToken))
->patchJson('/api/v1/notifications/email', [
'smtp_ehlo_domain' => 'not a hostname',
])
->assertUnprocessable()
->assertJsonValidationErrors('smtp_ehlo_domain');
});
test('clears the smtp ehlo domain', function () {
$this->team->emailNotificationSettings->update([
'smtp_ehlo_domain' => 'coolify.example.com',
]);
$this->withHeaders(authHeaders($this->bearerToken))
->patchJson('/api/v1/notifications/email', [
'smtp_ehlo_domain' => null,
])
->assertSuccessful()
->assertJsonPath('smtp_ehlo_domain', null);
expect($this->team->emailNotificationSettings->fresh()->smtp_ehlo_domain)->toBeNull();
});
test('updates discord notification settings', function () {
$response = $this->withHeaders(authHeaders($this->bearerToken))
->patchJson('/api/v1/notifications/discord', [

View file

@ -0,0 +1,32 @@
<?php
use App\Livewire\SwitchTeam;
use App\Models\InstanceSettings;
use App\Models\Team;
use App\Models\User;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Livewire\Livewire;
uses(RefreshDatabase::class);
beforeEach(function () {
InstanceSettings::forceCreate(['id' => 0]);
$this->user = User::factory()->create();
$this->currentTeam = $this->user->teams()->first();
$this->otherTeam = Team::factory()->create();
$this->otherTeam->members()->attach($this->user->id, ['role' => 'owner']);
$this->actingAs($this->user);
session(['currentTeam' => $this->currentTeam]);
});
test('switching teams keeps the current page URL', function () {
$currentUrl = route('security.api-tokens', ['page' => 2]);
Livewire::test(SwitchTeam::class)
->call('switch_to', $this->otherTeam->id, $currentUrl)
->assertRedirect($currentUrl);
expect(session('currentTeam')->is($this->otherTeam))->toBeTrue();
});

View file

@ -64,18 +64,34 @@
->not->toContain('Lost contact with Coolify');
});
it('uses sidebar state css instead of nested alpine state for upgrade labels', function () {
it('renders the upgrade control in the mobile top bar', function () {
$layout = file_get_contents(resource_path('views/layouts/app.blade.php'));
expect($layout)
->toMatch('/MOBILE TOP BAR[\s\S]*?<livewire:upgrade[^>]*>[\s\S]*?Open sidebar/')
->toContain('key="mobile-upgrade"');
});
it('supports a full size update button for settings', function () {
$upgradeView = file_get_contents(resource_path('views/livewire/upgrade.blade.php'));
$utilitiesCss = file_get_contents(resource_path('css/utilities.css'));
$settingsView = file_get_contents(resource_path('views/livewire/settings/updates.blade.php'));
expect($upgradeView)
->toContain('class="text-left menu-item-label sidebar-collapsed-label"')
->toContain('>In progress</span>')
->toContain('>Upgrade</span>')
->not->toContain(':class="collapsed && \'lg:hidden\'"')
->and($utilitiesCss)
->toContain('.sidebar-collapsed .sidebar-collapsed-label')
->toContain('display: none;');
->toContain('$fullButton')
->toContain('Upgrade now')
->and($settingsView)
->toContain('Update Coolify')
->toContain(':full-button="true"')
->toContain('key="settings-upgrade"');
});
it('uses compact labels that do not depend on desktop sidebar state', function () {
$upgradeView = file_get_contents(resource_path('views/livewire/upgrade.blade.php'));
expect($upgradeView)
->toContain('Updating')
->toContain('Update available')
->not->toContain(':class="collapsed');
});
it('falls back to 0.0.0 during mount when cached versions data is unavailable', function () {