From 6731d83d9dad5ace695170914e781786ca0b8937 Mon Sep 17 00:00:00 2001 From: Dharmveer <51308779+dharmveer97@users.noreply.github.com> Date: Mon, 19 Oct 2026 02:30:27 +0530 Subject: [PATCH] feat(notifications): add opt-in commit details to deployment emails (#12030) Co-authored-by: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Co-authored-by: Claude Opus 5.5 --- .../Api/NotificationsController.php | 1 + app/Jobs/ApplicationDeploymentJob.php | 8 +- app/Livewire/Notifications/Email.php | 17 + app/Models/EmailNotificationSettings.php | 2 + .../Application/DeploymentFailed.php | 10 +- .../Application/DeploymentSuccess.php | 10 +- .../IncludesDeploymentCommitDetails.php | 79 +++++ ...details_to_email_notification_settings.php | 26 ++ .../emails/deployment-commit.blade.php | 16 + .../application-deployment-failed.blade.php | 1 + .../application-deployment-success.blade.php | 1 + .../livewire/notifications/email.blade.php | 6 + tests/Feature/Api/NotificationsApiTest.php | 25 ++ .../DeploymentEmailCommitDetailsTest.php | 319 ++++++++++++++++++ tests/Feature/MultiServerApplicationTest.php | 19 ++ 15 files changed, 533 insertions(+), 7 deletions(-) create mode 100644 app/Traits/IncludesDeploymentCommitDetails.php create mode 100644 database/migrations/2026_10_18_120000_add_deployment_commit_details_to_email_notification_settings.php create mode 100644 resources/views/components/emails/deployment-commit.blade.php create mode 100644 tests/Feature/DeploymentEmailCommitDetailsTest.php diff --git a/app/Http/Controllers/Api/NotificationsController.php b/app/Http/Controllers/Api/NotificationsController.php index 1cca69a663..fe38dbd1d5 100644 --- a/app/Http/Controllers/Api/NotificationsController.php +++ b/app/Http/Controllers/Api/NotificationsController.php @@ -45,6 +45,7 @@ class NotificationsController extends Controller 'use_instance_email_settings' => 'sometimes|boolean', 'deployment_success_email_notifications' => 'sometimes|boolean', 'deployment_failure_email_notifications' => 'sometimes|boolean', + 'deployment_commit_details_email_notifications' => 'sometimes|boolean', 'status_change_email_notifications' => 'sometimes|boolean', 'restart_limit_reached_email_notifications' => 'sometimes|boolean', 'backup_success_email_notifications' => 'sometimes|boolean', diff --git a/app/Jobs/ApplicationDeploymentJob.php b/app/Jobs/ApplicationDeploymentJob.php index 9287e2030d..9c8a42ce2d 100644 --- a/app/Jobs/ApplicationDeploymentJob.php +++ b/app/Jobs/ApplicationDeploymentJob.php @@ -5990,9 +5990,11 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); } $unsuccessful = $deployments->first(fn (ApplicationDeploymentQueue $deployment) => $deployment->status !== ApplicationDeploymentStatus::FINISHED->value); + // Additional servers pull the image and do not clone, so only the main deployment has the commit message. + $parentDeployment = ApplicationDeploymentQueue::query()->where('deployment_uuid', $parentDeploymentUuid)->first(); $this->application->environment->project->team?->notify($unsuccessful - ? new DeploymentFailed($this->application, $unsuccessful->deployment_uuid, $this->preview) - : new DeploymentSuccess($this->application, $parentDeploymentUuid, $this->preview)); + ? new DeploymentFailed($this->application, $unsuccessful->deployment_uuid, $this->preview, $parentDeployment) + : new DeploymentSuccess($this->application, $parentDeploymentUuid, $this->preview, $parentDeployment)); } /** @@ -6001,7 +6003,7 @@ COPY ./nginx.conf /etc/nginx/conf.d/default.conf"); private function sendDeploymentNotification(string $notificationClass): void { $this->application->environment->project->team?->notify( - new $notificationClass($this->application, $this->deployment_uuid, $this->preview) + new $notificationClass($this->application, $this->deployment_uuid, $this->preview, $this->application_deployment_queue) ); } diff --git a/app/Livewire/Notifications/Email.php b/app/Livewire/Notifications/Email.php index 0a37c75d16..0913e93507 100644 --- a/app/Livewire/Notifications/Email.php +++ b/app/Livewire/Notifications/Email.php @@ -76,6 +76,9 @@ class Email extends Component #[Validate(['boolean'])] public bool $deploymentFailureEmailNotifications = true; + #[Validate(['boolean'])] + public bool $deploymentCommitDetailsEmailNotifications = false; + #[Validate(['boolean'])] public bool $statusChangeEmailNotifications = false; @@ -156,6 +159,7 @@ class Email extends Component $this->settings->deployment_success_email_notifications = $this->deploymentSuccessEmailNotifications; $this->settings->deployment_failure_email_notifications = $this->deploymentFailureEmailNotifications; + $this->settings->deployment_commit_details_email_notifications = $this->deploymentCommitDetailsEmailNotifications; $this->settings->status_change_email_notifications = $this->statusChangeEmailNotifications; $this->settings->restart_limit_reached_email_notifications = $this->restartLimitReachedEmailNotifications; $this->settings->backup_success_email_notifications = $this->backupSuccessEmailNotifications; @@ -197,6 +201,7 @@ class Email extends Component $this->deploymentSuccessEmailNotifications = $this->settings->deployment_success_email_notifications; $this->deploymentFailureEmailNotifications = $this->settings->deployment_failure_email_notifications; + $this->deploymentCommitDetailsEmailNotifications = $this->settings->deployment_commit_details_email_notifications; $this->statusChangeEmailNotifications = $this->settings->status_change_email_notifications; $this->restartLimitReachedEmailNotifications = $this->settings->restart_limit_reached_email_notifications; $this->backupSuccessEmailNotifications = $this->settings->backup_success_email_notifications; @@ -231,6 +236,18 @@ class Email extends Component $this->dispatch('success', 'Email notifications settings updated.'); } + public function instantSaveDeploymentCommitDetails() + { + try { + $this->resetErrorBag(); + $this->saveModel(); + } catch (\Throwable $e) { + $this->deploymentCommitDetailsEmailNotifications = $this->settings->deployment_commit_details_email_notifications; + + return handleError($e, $this); + } + } + public function instantSave(?string $type = null) { try { diff --git a/app/Models/EmailNotificationSettings.php b/app/Models/EmailNotificationSettings.php index 94d0802d96..98e530ce84 100644 --- a/app/Models/EmailNotificationSettings.php +++ b/app/Models/EmailNotificationSettings.php @@ -33,6 +33,7 @@ class EmailNotificationSettings extends Model 'deployment_success_email_notifications', 'deployment_failure_email_notifications', + 'deployment_commit_details_email_notifications', 'status_change_email_notifications', 'restart_limit_reached_email_notifications', 'backup_success_email_notifications', @@ -76,6 +77,7 @@ class EmailNotificationSettings extends Model 'deployment_success_email_notifications' => 'boolean', 'deployment_failure_email_notifications' => 'boolean', + 'deployment_commit_details_email_notifications' => 'boolean', 'status_change_email_notifications' => 'boolean', 'restart_limit_reached_email_notifications' => 'boolean', 'backup_success_email_notifications' => 'boolean', diff --git a/app/Notifications/Application/DeploymentFailed.php b/app/Notifications/Application/DeploymentFailed.php index 8fff7f03bb..376f120c88 100644 --- a/app/Notifications/Application/DeploymentFailed.php +++ b/app/Notifications/Application/DeploymentFailed.php @@ -3,15 +3,19 @@ namespace App\Notifications\Application; use App\Models\Application; +use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationPreview; use App\Notifications\CustomEmailNotification; use App\Notifications\Dto\DiscordMessage; use App\Notifications\Dto\PushoverMessage; use App\Notifications\Dto\SlackMessage; +use App\Traits\IncludesDeploymentCommitDetails; use Illuminate\Notifications\Messages\MailMessage; class DeploymentFailed extends CustomEmailNotification { + use IncludesDeploymentCommitDetails; + public Application $application; public ?ApplicationPreview $preview = null; @@ -30,7 +34,7 @@ class DeploymentFailed extends CustomEmailNotification public ?string $fqdn = null; - public function __construct(Application $application, string $deployment_uuid, ?ApplicationPreview $preview = null) + public function __construct(Application $application, string $deployment_uuid, ?ApplicationPreview $preview = null, ?ApplicationDeploymentQueue $deployment = null) { $this->onQueue('high'); $this->application = $application; @@ -45,6 +49,7 @@ class DeploymentFailed extends CustomEmailNotification $this->fqdn = str($this->fqdn)->explode(',')->first(); } $this->deployment_url = base_url()."/project/{$this->project_uuid}/environment/{$this->environment_uuid}/application/{$this->application->uuid}/deployment/{$this->deployment_uuid}"; + $this->resolveDeploymentCommitDetails($application, $deployment, $preview); } public function via(object $notifiable): array @@ -52,7 +57,7 @@ class DeploymentFailed extends CustomEmailNotification return $notifiable->getEnabledChannels('deployment_failure'); } - public function toMail(): MailMessage + public function toMail(?object $notifiable = null): MailMessage { $mail = new MailMessage; $pull_request_id = data_get($this->preview, 'pull_request_id', 0); @@ -64,6 +69,7 @@ class DeploymentFailed extends CustomEmailNotification $mail->subject('Coolify: Deployment failed of pull request #'.$this->preview->pull_request_id.' of '.$this->application_name.'.'); } $mail->view('emails.application-deployment-failed', [ + 'commit' => $this->deploymentCommitDetailsForMail($notifiable), 'name' => $this->application_name, 'fqdn' => $fqdn, 'deployment_url' => $this->deployment_url, diff --git a/app/Notifications/Application/DeploymentSuccess.php b/app/Notifications/Application/DeploymentSuccess.php index 415df5831d..07c3000fb5 100644 --- a/app/Notifications/Application/DeploymentSuccess.php +++ b/app/Notifications/Application/DeploymentSuccess.php @@ -3,15 +3,19 @@ namespace App\Notifications\Application; use App\Models\Application; +use App\Models\ApplicationDeploymentQueue; use App\Models\ApplicationPreview; use App\Notifications\CustomEmailNotification; use App\Notifications\Dto\DiscordMessage; use App\Notifications\Dto\PushoverMessage; use App\Notifications\Dto\SlackMessage; +use App\Traits\IncludesDeploymentCommitDetails; use Illuminate\Notifications\Messages\MailMessage; class DeploymentSuccess extends CustomEmailNotification { + use IncludesDeploymentCommitDetails; + public Application $application; public ?ApplicationPreview $preview = null; @@ -30,7 +34,7 @@ class DeploymentSuccess extends CustomEmailNotification public ?string $fqdn; - public function __construct(Application $application, string $deployment_uuid, ?ApplicationPreview $preview = null) + public function __construct(Application $application, string $deployment_uuid, ?ApplicationPreview $preview = null, ?ApplicationDeploymentQueue $deployment = null) { $this->onQueue('high'); $this->application = $application; @@ -45,6 +49,7 @@ class DeploymentSuccess extends CustomEmailNotification $this->fqdn = str($this->fqdn)->explode(',')->first(); } $this->deployment_url = base_url()."/project/{$this->project_uuid}/environment/{$this->environment_uuid}/application/{$this->application->uuid}/deployment/{$this->deployment_uuid}"; + $this->resolveDeploymentCommitDetails($application, $deployment, $preview); } public function via(object $notifiable): array @@ -52,7 +57,7 @@ class DeploymentSuccess extends CustomEmailNotification return $notifiable->getEnabledChannels('deployment_success'); } - public function toMail(): MailMessage + public function toMail(?object $notifiable = null): MailMessage { $mail = new MailMessage; $pull_request_id = data_get($this->preview, 'pull_request_id', 0); @@ -64,6 +69,7 @@ class DeploymentSuccess extends CustomEmailNotification $mail->subject("Coolify: Pull request #{$pull_request_id} of {$this->application_name} deployed successfully"); } $mail->view('emails.application-deployment-success', [ + 'commit' => $this->deploymentCommitDetailsForMail($notifiable), 'name' => $this->application_name, 'fqdn' => $fqdn, 'deployment_url' => $this->deployment_url, diff --git a/app/Traits/IncludesDeploymentCommitDetails.php b/app/Traits/IncludesDeploymentCommitDetails.php new file mode 100644 index 0000000000..6248481266 --- /dev/null +++ b/app/Traits/IncludesDeploymentCommitDetails.php @@ -0,0 +1,79 @@ +commit); + if (preg_match('/^[0-9a-f]{7,64}$/i', $commit) === 1) { + $this->commit_sha = $commit; + $this->commit_url = $application->gitCommitLink($commit); + } + + $message = trim(mb_scrub((string) $deployment->commitMessage(), 'UTF-8')); + if ($message !== '') { + $this->commit_message = Str::limit($message, 1000); + } + + if (is_null($preview) && filled($application->git_branch)) { + $this->commit_branch = $application->git_branch; + } + } + + /** @return array{sha: string|null, short_sha: string|null, url: string|null, branch: string|null, message: string|null}|null */ + protected function deploymentCommitDetailsForMail(?object $notifiable): ?array + { + $enabled = (bool) data_get($notifiable, 'emailNotificationSettings.deployment_commit_details_email_notifications', false); + + if (! $enabled || (is_null($this->commit_sha) && is_null($this->commit_message))) { + return null; + } + + return [ + 'sha' => $this->commit_sha, + 'short_sha' => $this->commit_sha ? substr($this->commit_sha, 0, 7) : null, + 'url' => $this->commit_url, + 'branch' => $this->commit_branch ? self::escapeMailMarkdown($this->commit_branch) : null, + 'message' => $this->commit_message ? self::formatCommitMessageForMail($this->commit_message) : null, + ]; + } + + /** Escape Markdown in user-controlled text; Blade escapes the HTML. */ + private static function escapeMailMarkdown(string $text): string + { + return preg_replace('/([\\\\`*_{}\[\]()#+\-.!|~=])/u', '\\\\$1', $text); + } + + private static function formatCommitMessageForMail(string $message): string + { + $paragraphs = preg_split('/\R\s*\R/u', str_replace("\r\n", "\n", $message)); + + return collect($paragraphs) + ->map(fn (string $paragraph): string => collect(preg_split('/\R/u', $paragraph)) + ->map(fn (string $line): string => self::escapeMailMarkdown(trim($line))) + ->filter(fn (string $line): bool => $line !== '') + ->implode("\\\n")) + ->filter(fn (string $paragraph): bool => $paragraph !== '') + ->implode("\n\n"); + } +} diff --git a/database/migrations/2026_10_18_120000_add_deployment_commit_details_to_email_notification_settings.php b/database/migrations/2026_10_18_120000_add_deployment_commit_details_to_email_notification_settings.php new file mode 100644 index 0000000000..2f8e613206 --- /dev/null +++ b/database/migrations/2026_10_18_120000_add_deployment_commit_details_to_email_notification_settings.php @@ -0,0 +1,26 @@ +boolean('deployment_commit_details_email_notifications')->default(false); + }); + } + } + + public function down(): void + { + if (Schema::hasColumn('email_notification_settings', 'deployment_commit_details_email_notifications')) { + Schema::table('email_notification_settings', function (Blueprint $table) { + $table->dropColumn('deployment_commit_details_email_notifications'); + }); + } + } +}; diff --git a/resources/views/components/emails/deployment-commit.blade.php b/resources/views/components/emails/deployment-commit.blade.php new file mode 100644 index 0000000000..bf5c23fcf1 --- /dev/null +++ b/resources/views/components/emails/deployment-commit.blade.php @@ -0,0 +1,16 @@ +@props(['commit' => null]) +@if ($commit) +@php + $commitLine = null; + if ($commit['short_sha']) { + $shortSha = e($commit['short_sha']); + $commitLine = '**Commit:** '.($commit['url'] ? "[{$shortSha}](".e($commit['url']).')' : $shortSha); + if ($commit['branch']) { + $commitLine .= ' on '.e($commit['branch']); + } + } + $messageBlock = $commit['message'] ? "**Commit message:**\n\n".e($commit['message']) : null; +@endphp +{!! collect([$commitLine, $messageBlock])->filter()->implode("\n\n") !!} + +@endif diff --git a/resources/views/emails/application-deployment-failed.blade.php b/resources/views/emails/application-deployment-failed.blade.php index a97c650b2a..937c8cdb8e 100644 --- a/resources/views/emails/application-deployment-failed.blade.php +++ b/resources/views/emails/application-deployment-failed.blade.php @@ -6,5 +6,6 @@ Failed to deploy a pull request #{{ $pull_request_id }} of {{ $name }} at [{{ $fqdn }}]({{ $fqdn }}). @endif + [View Deployment Logs]({{ $deployment_url }}) diff --git a/resources/views/emails/application-deployment-success.blade.php b/resources/views/emails/application-deployment-success.blade.php index bc09a24796..2a1b6321d8 100644 --- a/resources/views/emails/application-deployment-success.blade.php +++ b/resources/views/emails/application-deployment-success.blade.php @@ -6,6 +6,7 @@ Pull request #{{ $pull_request_id }} of {{ $name }} deployed successfully [{{ $fqdn }}]({{ $fqdn }}). @endif + [View Deployment Logs]({{ $deployment_url }}) diff --git a/resources/views/livewire/notifications/email.blade.php b/resources/views/livewire/notifications/email.blade.php index fdc2ac672a..81302b1806 100644 --- a/resources/views/livewire/notifications/email.blade.php +++ b/resources/views/livewire/notifications/email.blade.php @@ -176,6 +176,12 @@ ['property' => 'traefikOutdatedEmailNotifications', 'label' => 'Traefik proxy outdated', 'enabled' => $traefikOutdatedEmailNotifications], ]" /> +
+ +
diff --git a/tests/Feature/Api/NotificationsApiTest.php b/tests/Feature/Api/NotificationsApiTest.php index d6bb96d15a..d5f4908ed8 100644 --- a/tests/Feature/Api/NotificationsApiTest.php +++ b/tests/Feature/Api/NotificationsApiTest.php @@ -192,6 +192,31 @@ describe('PATCH /api/v1/notifications/*', function () { ->and($settings->deployment_failure_email_notifications)->toBeFalse(); }); + test('updates the deployment commit details email setting', function () { + $this->withHeaders(authHeaders($this->bearerToken)) + ->getJson('/api/v1/notifications/email') + ->assertSuccessful() + ->assertJsonPath('deployment_commit_details_email_notifications', false); + + $this->withHeaders(authHeaders($this->bearerToken)) + ->patchJson('/api/v1/notifications/email', [ + 'deployment_commit_details_email_notifications' => true, + ]) + ->assertSuccessful() + ->assertJsonPath('deployment_commit_details_email_notifications', true); + + expect(EmailNotificationSettings::query()->where('team_id', $this->team->id)->value('deployment_commit_details_email_notifications'))->toBeTruthy(); + }); + + test('validates the deployment commit details email setting', function () { + $this->withHeaders(authHeaders($this->bearerToken)) + ->patchJson('/api/v1/notifications/email', [ + 'deployment_commit_details_email_notifications' => 'not-a-boolean', + ]) + ->assertUnprocessable() + ->assertJsonValidationErrors('deployment_commit_details_email_notifications'); + }); + test('validates the smtp ehlo domain', function () { $this->withHeaders(authHeaders($this->bearerToken)) ->patchJson('/api/v1/notifications/email', [ diff --git a/tests/Feature/DeploymentEmailCommitDetailsTest.php b/tests/Feature/DeploymentEmailCommitDetailsTest.php new file mode 100644 index 0000000000..bbba70f796 --- /dev/null +++ b/tests/Feature/DeploymentEmailCommitDetailsTest.php @@ -0,0 +1,319 @@ + 0]); + + $this->team = Team::factory()->create(); + $server = Server::factory()->create(['team_id' => $this->team->id]); + $destination = StandaloneDocker::factory()->create([ + 'server_id' => $server->id, + 'network' => 'commit-details-'.fake()->unique()->word(), + ]); + $project = Project::factory()->create(['team_id' => $this->team->id]); + $environment = Environment::factory()->create(['project_id' => $project->id]); + + $this->application = Application::factory()->create([ + 'environment_id' => $environment->id, + 'destination_id' => $destination->id, + 'destination_type' => StandaloneDocker::class, + 'git_repository' => 'https://github.com/acme/shop.git', + 'git_branch' => 'main', + 'fqdn' => 'https://shop.example.com', + ]); +}); + +function createDeploymentRow(Application $application, array $attributes = []): ApplicationDeploymentQueue +{ + return ApplicationDeploymentQueue::create([ + 'application_id' => $application->id, + 'deployment_uuid' => 'deploy-'.fake()->unique()->uuid(), + 'commit' => DEPLOYMENT_EMAIL_COMMIT_SHA, + 'commit_message' => "Add checkout page\n\nHandles card and wallet payments.", + 'status' => 'finished', + ...$attributes, + ]); +} + +function enableCommitDetails(Team $team, bool $enabled = true): void +{ + $team->emailNotificationSettings->update(['deployment_commit_details_email_notifications' => $enabled]); + $team->refresh(); +} + +function renderDeploymentMail(string $notificationClass, Application $application, ApplicationDeploymentQueue $deployment, Team $team, ?ApplicationPreview $preview = null): string +{ + return (string) (new $notificationClass($application, $deployment->deployment_uuid, $preview, $deployment))->toMail($team)->render(); +} + +it('defaults the commit details setting to off', function () { + expect($this->team->emailNotificationSettings->fresh()->deployment_commit_details_email_notifications)->toBeFalse(); +}); + +it('includes commit details in deployment emails when enabled', function (string $notificationClass) { + enableCommitDetails($this->team); + $deployment = createDeploymentRow($this->application); + + $html = renderDeploymentMail($notificationClass, $this->application, $deployment, $this->team); + + expect($html) + ->toContain('3f9c2a7') + ->toContain('https://github.com/acme/shop/commit/'.DEPLOYMENT_EMAIL_COMMIT_SHA) + ->toContain('main') + ->toContain('Add checkout page') + ->toContain('Handles card and wallet payments.'); +})->with([DeploymentSuccess::class, DeploymentFailed::class]); + +it('leaves deployment emails unchanged when the setting is off', function (string $notificationClass) { + enableCommitDetails($this->team, false); + $deployment = createDeploymentRow($this->application); + + $html = renderDeploymentMail($notificationClass, $this->application, $deployment, $this->team); + + expect($html) + ->not->toContain('3f9c2a7') + ->not->toContain('Add checkout page') + ->toContain('View Deployment Logs'); +})->with([DeploymentSuccess::class, DeploymentFailed::class]); + +it('hides the commit block when the deployment has no commit information', function () { + enableCommitDetails($this->team); + $deployment = createDeploymentRow($this->application, ['commit' => 'HEAD', 'commit_message' => null]); + + $html = renderDeploymentMail(DeploymentSuccess::class, $this->application, $deployment, $this->team); + + expect($html) + ->not->toContain('Commit:') + ->not->toContain('Commit message:') + ->toContain('View Deployment Logs'); +}); + +it('shows the commit for preview deployments without the base branch', function () { + enableCommitDetails($this->team); + $preview = ApplicationPreview::create([ + 'application_id' => $this->application->id, + 'pull_request_id' => 42, + 'pull_request_html_url' => 'https://github.com/acme/shop/pull/42', + 'fqdn' => 'https://pr-42.shop.example.com', + ]); + $deployment = createDeploymentRow($this->application, ['pull_request_id' => 42]); + + $html = renderDeploymentMail(DeploymentSuccess::class, $this->application, $deployment, $this->team, $preview); + + expect($html) + ->toContain('Pull request #42') + ->toContain('3f9c2a7') + ->toContain('Add checkout page') + ->not->toContain(' on main'); +}); + +it('escapes html and markdown in commit messages', function () { + enableCommitDetails($this->team); + $deployment = createDeploymentRow($this->application, [ + 'commit_message' => "Fix bug\n[Verify your account](https://evil.example.com)\n# Big heading", + ]); + + $html = renderDeploymentMail(DeploymentSuccess::class, $this->application, $deployment, $this->team); + + expect($html) + ->not->toContain('') + ->toContain('<script>') + ->not->toContain('href="https://evil.example.com"') + ->toContain('[Verify your account](https://evil.example.com)') + ->not->toContain('

Big heading

') + ->toContain('# Big heading'); +}); + +it('lets team admins toggle commit details from email notification settings', function () { + $admin = User::factory()->create(); + $this->team->members()->attach($admin->id, ['role' => 'admin']); + $this->actingAs($admin); + session(['currentTeam' => $this->team]); + Cache::flush(); + + Livewire::test(NotificationEmail::class) + ->assertSet('deploymentCommitDetailsEmailNotifications', false) + ->set('deploymentCommitDetailsEmailNotifications', true) + ->call('instantSaveDeploymentCommitDetails') + ->assertHasNoErrors(); + + expect($this->team->emailNotificationSettings->fresh()->deployment_commit_details_email_notifications)->toBeTrue(); +}); + +it('does not let team members change the commit details setting', function () { + $member = User::factory()->create(); + $this->team->members()->attach($member->id, ['role' => 'member']); + $this->actingAs($member); + session(['currentTeam' => $this->team]); + Cache::flush(); + + Livewire::test(NotificationEmail::class) + ->set('deploymentCommitDetailsEmailNotifications', true) + ->call('instantSaveDeploymentCommitDetails') + ->assertSet('deploymentCommitDetailsEmailNotifications', false); + + expect($this->team->emailNotificationSettings->fresh()->deployment_commit_details_email_notifications)->toBeFalse(); +}); + +it('does not let admins of another team change the commit details setting', function () { + $otherTeam = Team::factory()->create(); + $outsider = User::factory()->create(); + $otherTeam->members()->attach($outsider->id, ['role' => 'admin']); + $this->actingAs($outsider); + session(['currentTeam' => $otherTeam]); + Cache::flush(); + + Livewire::test(NotificationEmail::class) + ->set('deploymentCommitDetailsEmailNotifications', true) + ->call('instantSaveDeploymentCommitDetails'); + + expect($this->team->emailNotificationSettings->fresh()->deployment_commit_details_email_notifications)->toBeFalse() + ->and($otherTeam->emailNotificationSettings->fresh()->deployment_commit_details_email_notifications)->toBeTrue(); +}); + +function bigMarkdownCommitMessage(): string +{ + return implode("\n", [ + 'feat(checkout): rebuild payment flow', + '', + '# Heading one', + 'Setext heading', + '==============', + '## Heading two', + '- bullet one', + '* bullet two', + '+ bullet three', + '1. first step', + '2. second step', + '> quoted text', + ' indented code line', + '```php', + 'echo "hello";', + '```', + '| Column A | Column B |', + '| -------- | -------- |', + '| cell 1 | cell 2 |', + '**bold** _italic_ ~~strike~~ `inline code`', + '[Reset your password](https://evil.example.com/reset)', + '![tracking pixel](https://evil.example.com/pixel.png)', + '', + '', + 'click me', + '---', + 'Co-authored-by: Jane Doe ', + 'Emoji check 🚀 ✅', + '', + str_repeat('Long body sentence for truncation. ', 60), + 'THIS_TAIL_SHOULD_BE_TRUNCATED', + ]); +} + +it('renders a big markdown commit message as plain safe text', function () { + enableCommitDetails($this->team); + $deployment = createDeploymentRow($this->application, ['commit_message' => bigMarkdownCommitMessage()]); + + $html = renderDeploymentMail(DeploymentSuccess::class, $this->application, $deployment, $this->team); + + expect($html) + ->toContain('feat(checkout): rebuild payment flow') + ->toContain('# Heading one') + ->toContain('==============') + ->toContain('- bullet one') + ->toContain('1. first step') + ->toContain('```php') + ->toContain('| Column A | Column B |') + ->toContain('**bold** _italic_ ~~strike~~ `inline code`') + ->toContain('Co-authored-by: Jane Doe <jane@example.com>') + ->toContain('Emoji check 🚀 ✅') + ->not->toContain("\u{FFFD}") + ->toContain('Long body sentence for truncation.') + ->not->toContain('THIS_TAIL_SHOULD_BE_TRUNCATED') + ->not->toContain('href="https://evil.example.com') + ->not->toContain('src="https://evil.example.com') + ->not->toContain('not->toContain('application, $deployment, $this->team); + + expect($html) + ->toContain('Fix caf') + ->toContain('menu (latin-1 byte)') + ->toContain('View Deployment Logs'); +}); + +it('builds commit details without querying the deployment queue', function (string $notificationClass) { + enableCommitDetails($this->team); + $deployment = createDeploymentRow($this->application); + + DB::flushQueryLog(); + DB::enableQueryLog(); + $notification = new $notificationClass($this->application, $deployment->deployment_uuid, null, $deployment); + $queries = collect(DB::getQueryLog())->pluck('query'); + DB::disableQueryLog(); + + expect($queries->filter(fn (string $query): bool => str_contains($query, 'application_deployment_queues')))->toBeEmpty() + ->and($notification->commit_sha)->toBe(DEPLOYMENT_EMAIL_COMMIT_SHA); +})->with([DeploymentSuccess::class, DeploymentFailed::class]); + +it('keeps the old constructor working without a deployment record', function () { + enableCommitDetails($this->team); + + $html = (string) (new DeploymentSuccess($this->application, 'legacy-call'))->toMail($this->team)->render(); + + expect($html) + ->toContain('View Deployment Logs') + ->not->toContain('Commit message:'); +}); + +it('passes the in-memory deployment record from the deployment job', function () { + Notification::fake(); + $this->team->emailNotificationSettings->update(['smtp_enabled' => true, 'deployment_success_email_notifications' => true]); + $deployment = createDeploymentRow($this->application); + $reflection = new ReflectionClass(ApplicationDeploymentJob::class); + $job = $reflection->newInstanceWithoutConstructor(); + foreach (['application' => $this->application, 'deployment_uuid' => $deployment->deployment_uuid, 'preview' => null, 'application_deployment_queue' => $deployment] as $name => $value) { + $reflection->getProperty($name)->setValue($job, $value); + } + + $reflection->getMethod('sendDeploymentNotification')->invoke($job, DeploymentSuccess::class); + + Notification::assertSentTo($this->team, DeploymentSuccess::class, fn (DeploymentSuccess $notification) => $notification->commit_sha === DEPLOYMENT_EMAIL_COMMIT_SHA + && str_starts_with($notification->commit_message, 'Add checkout page')); +}); diff --git a/tests/Feature/MultiServerApplicationTest.php b/tests/Feature/MultiServerApplicationTest.php index 2f586e07b8..8f47010ac3 100644 --- a/tests/Feature/MultiServerApplicationTest.php +++ b/tests/Feature/MultiServerApplicationTest.php @@ -320,6 +320,25 @@ describe('combined deployment notification', function () { Notification::assertNothingSent(); }); + test('takes commit details for the summary from the main deployment', function () { + $parent = ApplicationDeploymentQueue::create([ + 'application_id' => $this->application->id, + 'deployment_uuid' => new_public_id(), + 'server_id' => $this->mainServer->id, + 'status' => ApplicationDeploymentStatus::FINISHED->value, + 'commit' => 'a1b2c3d4e5f60718293a4b5c6d7e8f9012345678', + 'commit_message' => 'Add checkout page', + ]); + Cache::put("multi-server-deployment-queued:{$parent->deployment_uuid}", true); + $child = createMultiServerDeployment($this->application, $this->extraServer, ApplicationDeploymentStatus::FINISHED->value, $parent->deployment_uuid); + $job = makeMultiServerDeploymentJob(['application' => $this->application, 'application_deployment_queue' => $child]); + + callMultiServerDeploymentJob($job, 'sendMultiServerDeploymentNotification', $parent->deployment_uuid); + + Notification::assertSentTo($this->team, DeploymentSuccess::class, fn (DeploymentSuccess $notification) => $notification->commit_sha === $parent->commit + && $notification->commit_message === 'Add checkout page'); + }); + test('sends a failed notification that links to the failed server deployment', function () { $parentUuid = new_public_id(); Cache::put("multi-server-deployment-queued:{$parentUuid}", true);