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:
Dharmveer
2026-10-18 23:00:27 +02:00
committed by GitHub
co-authored by Andras Bacsai Claude Opus 5.5
parent f979499db9
commit 6731d83d9d
15 changed files with 533 additions and 7 deletions
@@ -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',
+5 -3
View File
@@ -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)
);
}
+17
View File
@@ -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 {
+2
View File
@@ -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");
}
}
@@ -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('&lt;script&gt;')
->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)',
'![tracking pixel](https://evil.example.com/pixel.png)',
'<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 &lt;jane@example.com&gt;')
->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);