mirror of
https://github.com/coollabsio/coolify.git
synced 2026-11-06 13:10:37 -04:00
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Andras Bacsai
Claude Opus 5.5
parent
f979499db9
commit
6731d83d9d
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
<?php
|
||||
|
||||
namespace App\Traits;
|
||||
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\ApplicationPreview;
|
||||
use Illuminate\Support\Str;
|
||||
|
||||
trait IncludesDeploymentCommitDetails
|
||||
{
|
||||
public ?string $commit_sha = null;
|
||||
|
||||
public ?string $commit_message = null;
|
||||
|
||||
public ?string $commit_url = null;
|
||||
|
||||
public ?string $commit_branch = null;
|
||||
|
||||
/** Store plain strings from the job's in-memory deployment, so no query runs here. */
|
||||
protected function resolveDeploymentCommitDetails(Application $application, ?ApplicationDeploymentQueue $deployment, ?ApplicationPreview $preview = null): void
|
||||
{
|
||||
if (! $deployment) {
|
||||
return;
|
||||
}
|
||||
|
||||
$commit = trim((string) $deployment->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");
|
||||
}
|
||||
}
|
||||
+26
@@ -0,0 +1,26 @@
|
||||
<?php
|
||||
|
||||
use Illuminate\Database\Migrations\Migration;
|
||||
use Illuminate\Database\Schema\Blueprint;
|
||||
use Illuminate\Support\Facades\Schema;
|
||||
|
||||
return new class extends Migration
|
||||
{
|
||||
public function up(): void
|
||||
{
|
||||
if (! Schema::hasColumn('email_notification_settings', 'deployment_commit_details_email_notifications')) {
|
||||
Schema::table('email_notification_settings', function (Blueprint $table) {
|
||||
$table->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');
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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
|
||||
@@ -6,5 +6,6 @@ Failed to deploy a pull request #{{ $pull_request_id }} of {{ $name }} at
|
||||
[{{ $fqdn }}]({{ $fqdn }}).
|
||||
@endif
|
||||
|
||||
<x-emails.deployment-commit :commit="$commit ?? null" />
|
||||
[View Deployment Logs]({{ $deployment_url }})
|
||||
</x-emails.layout>
|
||||
|
||||
@@ -6,6 +6,7 @@ Pull request #{{ $pull_request_id }} of {{ $name }} deployed successfully
|
||||
[{{ $fqdn }}]({{ $fqdn }}).
|
||||
@endif
|
||||
|
||||
<x-emails.deployment-commit :commit="$commit ?? null" />
|
||||
[View Deployment Logs]({{ $deployment_url }})
|
||||
|
||||
</x-emails.layout>
|
||||
|
||||
@@ -176,6 +176,12 @@
|
||||
['property' => 'traefikOutdatedEmailNotifications', 'label' => 'Traefik proxy outdated', 'enabled' => $traefikOutdatedEmailNotifications],
|
||||
]" />
|
||||
</div>
|
||||
<div class="mt-4">
|
||||
<x-forms.checkbox canGate="update" :canResource="$settings" instantSave="instantSaveDeploymentCommitDetails"
|
||||
id="deploymentCommitDetailsEmailNotifications"
|
||||
label="Include commit details in deployment emails"
|
||||
helper="Adds the commit SHA, branch, and commit message to deployment success and failure emails." />
|
||||
</div>
|
||||
</x-application.settings-section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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', [
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
<?php
|
||||
|
||||
use App\Jobs\ApplicationDeploymentJob;
|
||||
use App\Livewire\Notifications\Email as NotificationEmail;
|
||||
use App\Models\Application;
|
||||
use App\Models\ApplicationDeploymentQueue;
|
||||
use App\Models\ApplicationPreview;
|
||||
use App\Models\Environment;
|
||||
use App\Models\InstanceSettings;
|
||||
use App\Models\Project;
|
||||
use App\Models\Server;
|
||||
use App\Models\StandaloneDocker;
|
||||
use App\Models\Team;
|
||||
use App\Models\User;
|
||||
use App\Notifications\Application\DeploymentFailed;
|
||||
use App\Notifications\Application\DeploymentSuccess;
|
||||
use Illuminate\Foundation\Testing\RefreshDatabase;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Notification;
|
||||
use Livewire\Livewire;
|
||||
|
||||
uses(RefreshDatabase::class);
|
||||
|
||||
const DEPLOYMENT_EMAIL_COMMIT_SHA = '3f9c2a7b1d4e5f60718293a4b5c6d7e8f9012345';
|
||||
|
||||
beforeEach(function () {
|
||||
InstanceSettings::forceCreate(['id' => 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 <script>alert(1)</script> 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('<script>alert(1)</script>')
|
||||
->toContain('<script>')
|
||||
->not->toContain('href="https://evil.example.com"')
|
||||
->toContain('[Verify your account](https://evil.example.com)')
|
||||
->not->toContain('<h1>Big heading</h1>')
|
||||
->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)',
|
||||
'',
|
||||
'<https://evil.example.com/autolink>',
|
||||
'<img src="https://evil.example.com/x.png" onerror="alert(1)">',
|
||||
'<a href="https://evil.example.com">click me</a>',
|
||||
'---',
|
||||
'Co-authored-by: Jane Doe <jane@example.com>',
|
||||
'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('<img')
|
||||
->not->toContain('<a href="https://evil')
|
||||
->not->toContain('<h1>')
|
||||
->not->toContain('<h2>')
|
||||
->not->toContain('<ul>')
|
||||
->not->toContain('<ol>')
|
||||
->not->toContain('<blockquote>')
|
||||
->not->toContain('<pre>')
|
||||
->not->toContain('<table>')
|
||||
->not->toContain('<strong>bold</strong>')
|
||||
->toContain('---<br />');
|
||||
});
|
||||
|
||||
it('keeps the email working when a commit message has invalid utf-8 bytes', function () {
|
||||
enableCommitDetails($this->team);
|
||||
$deployment = createDeploymentRow($this->application, ['commit_message' => "Fix caf\xE9 menu (latin-1 byte)"]);
|
||||
|
||||
$html = renderDeploymentMail(DeploymentSuccess::class, $this->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'));
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user