From 89480bcc61636856c186ca8a279d8eeb4e3db973 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 19 Aug 2026 11:57:26 +0200 Subject: [PATCH] feat(api): add instance email settings endpoints Add root-team-authorized API access for SMTP and Resend settings with validation, sensitive-field controls, auditing, and coverage. --- .../Api/InstanceEmailSettingsController.php | 97 ++++++++++++ routes/api.php | 3 + .../Api/InstanceEmailSettingsApiTest.php | 141 ++++++++++++++++++ 3 files changed, 241 insertions(+) create mode 100644 app/Http/Controllers/Api/InstanceEmailSettingsController.php create mode 100644 tests/Feature/Api/InstanceEmailSettingsApiTest.php diff --git a/app/Http/Controllers/Api/InstanceEmailSettingsController.php b/app/Http/Controllers/Api/InstanceEmailSettingsController.php new file mode 100644 index 0000000000..ad84b6629d --- /dev/null +++ b/app/Http/Controllers/Api/InstanceEmailSettingsController.php @@ -0,0 +1,97 @@ + []]], tags: ['Settings'], + responses: [ + new OA\Response(response: 200, description: 'Instance email settings.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Forbidden.'), + ] + )] + public function show(): JsonResponse + { + $settings = InstanceSettings::get(); + $this->authorizeRootTeam('view', $settings); + + return response()->json($this->serialize($settings)); + } + + #[OA\Patch( + summary: 'Update instance email settings', + description: 'Update instance-wide SMTP and Resend settings. Requires `write:sensitive` and a root-team token belonging to a root-team admin or owner.', + path: '/settings/email', operationId: 'update-instance-email-settings', + security: [['bearerAuth' => []]], tags: ['Settings'], + responses: [ + new OA\Response(response: 200, description: 'Updated instance email settings.'), + new OA\Response(response: 401, ref: '#/components/responses/401'), + new OA\Response(response: 403, description: 'Forbidden.'), + new OA\Response(response: 422, ref: '#/components/responses/422'), + ] + )] + public function update(Request $request): JsonResponse + { + $settings = InstanceSettings::get(); + $this->authorizeRootTeam('update', $settings); + + $validator = customApiValidator($request->json()->all(), [ + 'smtp_enabled' => 'sometimes|boolean', + 'smtp_from_address' => 'sometimes|nullable|email', + 'smtp_from_name' => 'sometimes|nullable|string|max:255', + 'smtp_host' => 'sometimes|nullable|string|max:255', + 'smtp_port' => 'sometimes|nullable|integer|min:1|max:65535', + 'smtp_encryption' => 'sometimes|nullable|string|in:starttls,tls,none', + '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', + ]); + + if ($validator->fails()) { + return response()->json(['message' => 'Validation failed.', 'errors' => $validator->errors()], 422); + } + + $settings->fill($validator->validated()); + $settings->save(); + + auditLog('api.settings.email.updated', ['changed_fields' => array_keys($validator->validated())]); + + return response()->json($this->serialize($settings->refresh())); + } + + private function authorizeRootTeam(string $ability, InstanceSettings $settings): void + { + $teamId = getTeamIdFromToken(); + abort_unless(! is_null($teamId) && (int) $teamId === 0, 403, 'Instance email settings require a root-team API token.'); + $this->authorize($ability, $settings); + } + + private function serialize(InstanceSettings $settings): array + { + exposeSensitiveFields($settings); + + return Arr::only($settings->toArray(), self::FIELDS); + } +} diff --git a/routes/api.php b/routes/api.php index 0ce9a7f11f..2d19ae96dc 100644 --- a/routes/api.php +++ b/routes/api.php @@ -10,6 +10,7 @@ use App\Http\Controllers\Api\DigitalOceanController; use App\Http\Controllers\Api\GithubController; use App\Http\Controllers\Api\GitlabController; use App\Http\Controllers\Api\HetznerController; +use App\Http\Controllers\Api\InstanceEmailSettingsController; use App\Http\Controllers\Api\NotificationsController; use App\Http\Controllers\Api\OtherController; use App\Http\Controllers\Api\ProjectController; @@ -83,6 +84,8 @@ Route::group([ Route::patch('/notifications/pushover', [NotificationsController::class, 'update_pushover'])->middleware(['api.ability:write']); Route::get('/notifications/webhook', [NotificationsController::class, 'webhook'])->middleware(['api.ability:read']); Route::patch('/notifications/webhook', [NotificationsController::class, 'update_webhook'])->middleware(['api.ability:write']); + Route::get('/settings/email', [InstanceEmailSettingsController::class, 'show'])->middleware(['api.ability:read']); + Route::patch('/settings/email', [InstanceEmailSettingsController::class, 'update'])->middleware(['api.ability:write:sensitive']); Route::get('/team/envs', [SharedEnvironmentVariablesController::class, 'team_envs'])->middleware(['api.ability:read']); Route::post('/team/envs', [SharedEnvironmentVariablesController::class, 'team_create_env'])->middleware(['api.ability:write']); Route::patch('/team/envs/{env_id}', [SharedEnvironmentVariablesController::class, 'team_update_env'])->middleware(['api.ability:write']); diff --git a/tests/Feature/Api/InstanceEmailSettingsApiTest.php b/tests/Feature/Api/InstanceEmailSettingsApiTest.php new file mode 100644 index 0000000000..116481faa9 --- /dev/null +++ b/tests/Feature/Api/InstanceEmailSettingsApiTest.php @@ -0,0 +1,141 @@ + 'file', + 'cache.default' => 'array', + 'session.driver' => 'array', + ]); + + InstanceSettings::query()->whereKey(0)->delete(); + $settings = new InstanceSettings(['is_api_enabled' => true]); + $settings->id = 0; + $settings->save(); + Once::flush(); + + $this->rootTeam = Team::factory()->create(['id' => 0]); +}); + +function instanceEmailToken(User $user, Team $team, string $role, array $abilities): string +{ + $team->members()->attach($user->id, ['role' => $role]); + session(['currentTeam' => $team]); + + return $user->createToken('instance-email-test', $abilities)->plainTextToken; +} + +function instanceEmailHeaders(string $token): array +{ + return [ + 'Authorization' => 'Bearer '.$token, + 'Content-Type' => 'application/json', + 'Accept' => 'application/json', + ]; +} + +test('root team owners can get instance email settings', function () { + InstanceSettings::findOrFail(0)->update([ + 'smtp_enabled' => true, + 'smtp_ehlo_domain' => 'coolify.example.com', + ]); + $token = instanceEmailToken(User::factory()->create(), $this->rootTeam, 'owner', ['read']); + + $this->withHeaders(instanceEmailHeaders($token)) + ->getJson('/api/v1/settings/email') + ->assertSuccessful() + ->assertJsonPath('smtp_enabled', true) + ->assertJsonPath('smtp_ehlo_domain', 'coolify.example.com') + ->assertJsonMissingPath('smtp_password'); +}); + +test('root team admins can update instance email settings', function () { + $token = instanceEmailToken(User::factory()->create(), $this->rootTeam, 'admin', ['write:sensitive']); + + $this->withHeaders(instanceEmailHeaders($token)) + ->patchJson('/api/v1/settings/email', [ + 'smtp_enabled' => true, + 'smtp_from_address' => 'alerts@example.com', + 'smtp_from_name' => 'Coolify', + 'smtp_host' => 'smtp.example.com', + 'smtp_port' => 587, + 'smtp_encryption' => 'starttls', + 'smtp_username' => 'coolify', + 'smtp_password' => 'secret', + 'smtp_timeout' => 10, + 'smtp_ehlo_domain' => 'coolify.example.com', + ]) + ->assertSuccessful() + ->assertJsonPath('smtp_ehlo_domain', 'coolify.example.com'); + + $settings = InstanceSettings::findOrFail(0); + expect($settings->smtp_enabled)->toBeTrue() + ->and($settings->smtp_host)->toBe('smtp.example.com') + ->and($settings->smtp_ehlo_domain)->toBe('coolify.example.com'); +}); + +test('instance email settings reject non-root teams', function () { + $team = Team::factory()->create(); + $token = instanceEmailToken(User::factory()->create(), $team, 'owner', ['read', 'write']); + + $this->withHeaders(instanceEmailHeaders($token)) + ->getJson('/api/v1/settings/email') + ->assertForbidden(); + + $this->withHeaders(instanceEmailHeaders($token)) + ->patchJson('/api/v1/settings/email', ['smtp_enabled' => true]) + ->assertForbidden(); +}); + +test('instance email settings reject root team members', function () { + $token = instanceEmailToken(User::factory()->create(), $this->rootTeam, 'member', ['read']); + + $this->withHeaders(instanceEmailHeaders($token)) + ->getJson('/api/v1/settings/email') + ->assertForbidden(); +}); + +test('instance email settings validate the smtp ehlo domain', function () { + $token = instanceEmailToken(User::factory()->create(), $this->rootTeam, 'owner', ['write:sensitive']); + + $this->withHeaders(instanceEmailHeaders($token)) + ->patchJson('/api/v1/settings/email', ['smtp_ehlo_domain' => 'not a hostname']) + ->assertUnprocessable() + ->assertJsonValidationErrors('smtp_ehlo_domain'); +}); + +test('updating instance email settings requires write sensitive', function () { + $token = instanceEmailToken(User::factory()->create(), $this->rootTeam, 'owner', ['write']); + + $this->withHeaders(instanceEmailHeaders($token)) + ->patchJson('/api/v1/settings/email', ['smtp_enabled' => true]) + ->assertForbidden(); +}); + +test('root admins cannot use a token issued for another team', function () { + $user = User::factory()->create(); + $this->rootTeam->members()->attach($user->id, ['role' => 'owner']); + $team = Team::factory()->create(); + $token = instanceEmailToken($user, $team, 'owner', ['read', 'write:sensitive']); + + $this->withHeaders(instanceEmailHeaders($token)) + ->getJson('/api/v1/settings/email') + ->assertForbidden(); +}); + +test('read sensitive exposes instance email secrets to root team admins', function () { + InstanceSettings::findOrFail(0)->update(['smtp_password' => 'secret']); + $token = instanceEmailToken(User::factory()->create(), $this->rootTeam, 'admin', ['read', 'read:sensitive']); + + $this->withHeaders(instanceEmailHeaders($token)) + ->getJson('/api/v1/settings/email') + ->assertSuccessful() + ->assertJsonPath('smtp_password', 'secret'); +});