feat(notifications): deduplicate repeated email alerts

Add notification-level deduplication keys and TTLs for deployment, backup,
server, container, scheduled task, SSL, token, and transactional emails.
Apply deduplication in email channels before sending rendered messages.
This commit is contained in:
Andras Bacsai committed 2026-07-08 15:23:14 +02:00
1 parent ee620a5413
commit 8c1405e168
33 files changed
+516 -14

No files matched your search

@@ -48,6 +48,24 @@ it('does not show the restart limit warning for a normal manual stop', function
expect($html)->not->toContain('Stopped after reaching restart limit');
});
it('uses a semantic dedupe key for restart limit notifications', function () {
$application = applicationWithRestartState();
$application->forceFill([
'name' => 'crashy-app',
'uuid' => 'application-uuid',
]);
$application->setRelation('environment', (object) [
'uuid' => 'environment-uuid',
'name' => 'production',
'project' => (object) ['uuid' => 'project-uuid'],
]);
$notification = new RestartLimitReached($application);
expect($notification->deduplicationKey((object) ['id' => 1], 'mail'))->toBe('restart-limit-reached:application:application-uuid:count:2')
->and($notification->deduplicateFor())->toBe(86400);
});
it('keeps restart tracking configurable when stopping an application', function () {
$method = new ReflectionMethod(StopApplication::class, 'handle');
$resetRestartCount = collect($method->getParameters())->firstWhere('name', 'resetRestartCount');
@@ -0,0 +1,75 @@
<?php
use App\Notifications\CustomEmailNotification;
use App\Services\NotificationDeduplicator;
use Illuminate\Notifications\Messages\MailMessage;
use Illuminate\Support\Facades\Cache;
class DedupeTestNotification extends CustomEmailNotification
{
public function __construct(
public ?string $semanticKey = null,
public bool $deduplicate = true,
public int $ttl = 900,
) {}
public function toMail(): MailMessage
{
return (new MailMessage)->subject('Test');
}
public function shouldDeduplicate(): bool
{
return $this->deduplicate;
}
public function deduplicateFor(): int
{
return $this->ttl;
}
public function deduplicationKey(object $notifiable, string $channel): ?string
{
return $this->semanticKey;
}
}
beforeEach(function () {
Cache::flush();
$this->deduplicator = app(NotificationDeduplicator::class);
$this->notifiable = new class
{
public int $id = 123;
};
});
it('allows only the first identical notification fingerprint during the ttl', function () {
$notification = new DedupeTestNotification;
expect($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', '<p>Body</p>'))->toBeTrue()
->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', '<p>Body</p>'))->toBeFalse();
});
it('allows different recipients and content through the default fingerprint', function () {
$notification = new DedupeTestNotification;
expect($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', '<p>Body</p>'))->toBeTrue()
->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['second@example.com'], 'Subject', '<p>Body</p>'))->toBeTrue()
->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Other subject', '<p>Body</p>'))->toBeTrue()
->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', '<p>Other body</p>'))->toBeTrue();
});
it('uses semantic keys instead of rendered content when provided', function () {
$notification = new DedupeTestNotification(semanticKey: 'event:123');
expect($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', '<p>Body</p>'))->toBeTrue()
->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Other subject', '<p>Other body</p>'))->toBeFalse()
->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['second@example.com'], 'Other subject', '<p>Other body</p>'))->toBeTrue();
});
it('allows notifications to opt out of deduplication', function () {
$notification = new DedupeTestNotification(deduplicate: false);
expect($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', '<p>Body</p>'))->toBeTrue()
->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', '<p>Body</p>'))->toBeTrue();
});