From 8c1405e1689c7f26e27f062bb35025f76df02b05 Mon Sep 17 00:00:00 2001 From: Andras Bacsai <5845193+andrasbacsai@users.noreply.github.com> Date: Wed, 8 Jul 2026 15:23:14 +0200 Subject: [PATCH] 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. --- .../ApiTokenExpiringNotification.php | 10 ++ .../Application/DeploymentFailed.php | 10 ++ .../Application/DeploymentSuccess.php | 10 ++ .../Application/RestartLimitReached.php | 10 ++ .../Application/StatusChanged.php | 11 +++ app/Notifications/Channels/EmailChannel.php | 38 +++++--- .../Channels/TransactionalEmailChannel.php | 11 ++- .../Container/ContainerRestarted.php | 10 ++ .../Container/ContainerStopped.php | 10 ++ app/Notifications/CustomEmailNotification.php | 15 +++ app/Notifications/Database/BackupFailed.php | 13 +++ app/Notifications/Database/BackupSuccess.php | 13 +++ .../Database/BackupSuccessWithS3Warning.php | 13 +++ .../ScheduledTask/TaskFailed.php | 10 ++ .../ScheduledTask/TaskSuccess.php | 10 ++ .../Server/DockerCleanupFailed.php | 10 ++ .../Server/DockerCleanupSuccess.php | 10 ++ app/Notifications/Server/ForceDisabled.php | 10 ++ app/Notifications/Server/ForceEnabled.php | 10 ++ .../Server/HetznerDeletionFailed.php | 10 ++ app/Notifications/Server/HighDiskUsage.php | 10 ++ app/Notifications/Server/Reachable.php | 10 ++ app/Notifications/Server/ServerPatchCheck.php | 10 ++ .../Server/TraefikVersionOutdated.php | 12 +++ app/Notifications/Server/Unreachable.php | 10 ++ .../SslExpirationNotification.php | 16 ++++ app/Notifications/Test.php | 5 + .../EmailChangeVerification.php | 10 ++ .../TransactionalEmails/InvitationLink.php | 10 ++ .../TransactionalEmails/Test.php | 5 + app/Services/NotificationDeduplicator.php | 95 +++++++++++++++++++ ...pplicationStoppedAfterRestartLimitTest.php | 18 ++++ .../Feature/NotificationDeduplicationTest.php | 75 +++++++++++++++ 33 files changed, 516 insertions(+), 14 deletions(-) create mode 100644 app/Services/NotificationDeduplicator.php create mode 100644 tests/Feature/NotificationDeduplicationTest.php diff --git a/app/Notifications/ApiTokenExpiringNotification.php b/app/Notifications/ApiTokenExpiringNotification.php index 451dd312a..c00ac2d12 100644 --- a/app/Notifications/ApiTokenExpiringNotification.php +++ b/app/Notifications/ApiTokenExpiringNotification.php @@ -29,6 +29,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('api_token_expiring'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "api-token-expiring:{$this->token->id}"; + } + + public function deduplicateFor(): int + { + return 172800; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Application/DeploymentFailed.php b/app/Notifications/Application/DeploymentFailed.php index 8fff7f03b..0ed705edd 100644 --- a/app/Notifications/Application/DeploymentFailed.php +++ b/app/Notifications/Application/DeploymentFailed.php @@ -52,6 +52,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('deployment_failure'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "deployment-failed:{$this->deployment_uuid}"; + } + + public function deduplicateFor(): int + { + return 86400; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Application/DeploymentSuccess.php b/app/Notifications/Application/DeploymentSuccess.php index 415df5831..56b692cda 100644 --- a/app/Notifications/Application/DeploymentSuccess.php +++ b/app/Notifications/Application/DeploymentSuccess.php @@ -52,6 +52,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('deployment_success'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "deployment-success:{$this->deployment_uuid}"; + } + + public function deduplicateFor(): int + { + return 86400; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Application/RestartLimitReached.php b/app/Notifications/Application/RestartLimitReached.php index 635dfdbdc..507bba28d 100644 --- a/app/Notifications/Application/RestartLimitReached.php +++ b/app/Notifications/Application/RestartLimitReached.php @@ -49,6 +49,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('status_change'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "restart-limit-reached:application:{$this->resource->uuid}:count:{$this->restart_count}"; + } + + public function deduplicateFor(): int + { + return 86400; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Application/StatusChanged.php b/app/Notifications/Application/StatusChanged.php index ef61b7e6a..87986435d 100644 --- a/app/Notifications/Application/StatusChanged.php +++ b/app/Notifications/Application/StatusChanged.php @@ -42,6 +42,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('status_change'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "application-status-changed:application:{$this->resource->uuid}:stopped"; + } + + public function deduplicateFor(): int + { + return 3600; + } + public function toMail(): MailMessage { $mail = new MailMessage; @@ -50,6 +60,7 @@ public function toMail(): MailMessage $mail->view('emails.application-status-changes', [ 'name' => $this->resource_name, 'fqdn' => $fqdn, + 'application_url' => $this->resource_url, 'resource_url' => $this->resource_url, ]); diff --git a/app/Notifications/Channels/EmailChannel.php b/app/Notifications/Channels/EmailChannel.php index abd115550..45c6cb2d6 100644 --- a/app/Notifications/Channels/EmailChannel.php +++ b/app/Notifications/Channels/EmailChannel.php @@ -4,13 +4,20 @@ use App\Exceptions\NonReportableException; use App\Models\Team; +use App\Services\NotificationDeduplicator; use Exception; use Illuminate\Notifications\Notification; use Resend; +use Resend\Exceptions\ErrorException; +use Resend\Exceptions\TransporterException; +use Symfony\Component\Mailer\Mailer; +use Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport; +use Symfony\Component\Mime\Address; +use Symfony\Component\Mime\Email; class EmailChannel { - public function __construct() {} + public function __construct(private NotificationDeduplicator $deduplicator) {} public function send(SendsEmail $notifiable, Notification $notification): void { @@ -67,6 +74,11 @@ public function send(SendsEmail $notifiable, Notification $notification): void } $mailMessage = $notification->toMail($notifiable); + $renderedMail = (string) $mailMessage->render(); + + if (! $this->deduplicator->shouldSend($notifiable, $notification, self::class, $recipients, $mailMessage->subject, $renderedMail)) { + return; + } if ($isResendEnabled) { $resend = Resend::client($settings->resend_api_key); @@ -75,17 +87,17 @@ public function send(SendsEmail $notifiable, Notification $notification): void 'from' => $from, 'to' => $recipients, 'subject' => $mailMessage->subject, - 'html' => (string) $mailMessage->render(), + 'html' => $renderedMail, ]); } elseif ($isSmtpEnabled) { - $encryption = match (strtolower($settings->smtp_encryption)) { + $encryption = match (strtolower($settings->smtp_encryption ?? '')) { 'starttls' => null, 'tls' => 'tls', 'none' => null, default => null, }; - $transport = new \Symfony\Component\Mailer\Transport\Smtp\EsmtpTransport( + $transport = new EsmtpTransport( $settings->smtp_host, $settings->smtp_port, $encryption @@ -93,20 +105,20 @@ public function send(SendsEmail $notifiable, Notification $notification): void $transport->setUsername($settings->smtp_username ?? ''); $transport->setPassword($settings->smtp_password ?? ''); - $mailer = new \Symfony\Component\Mailer\Mailer($transport); + $mailer = new Mailer($transport); $fromEmail = $settings->smtp_from_address ?? 'noreply@localhost'; $fromName = $settings->smtp_from_name ?? 'System'; - $from = new \Symfony\Component\Mime\Address($fromEmail, $fromName); - $email = (new \Symfony\Component\Mime\Email) + $from = new Address($fromEmail, $fromName); + $email = (new Email) ->from($from) ->to(...$recipients) ->subject($mailMessage->subject) - ->html((string) $mailMessage->render()); + ->html($renderedMail); $mailer->send($email); } - } catch (\Resend\Exceptions\ErrorException $e) { + } catch (ErrorException $e) { // Map HTTP status codes to user-friendly messages $userMessage = match ($e->getErrorCode()) { 403 => 'Invalid Resend API key. Please verify your API key in the Resend dashboard and update it in settings.', @@ -131,13 +143,13 @@ public function send(SendsEmail $notifiable, Notification $notification): void // Don't report expected errors (invalid keys, validation) to Sentry if (in_array($e->getErrorCode(), [403, 401, 400])) { - throw NonReportableException::fromException(new \Exception($userMessage, $e->getCode(), $e)); + throw NonReportableException::fromException(new Exception($userMessage, $e->getCode(), $e)); } - throw new \Exception($userMessage, $e->getCode(), $e); - } catch (\Resend\Exceptions\TransporterException $e) { + throw new Exception($userMessage, $e->getCode(), $e); + } catch (TransporterException $e) { send_internal_notification("Resend Transport Error: {$e->getMessage()}"); - throw new \Exception('Unable to connect to Resend API. Please check your internet connection and try again.'); + throw new Exception('Unable to connect to Resend API. Please check your internet connection and try again.'); } catch (\Throwable $e) { // Check if this is a Resend domain verification error on cloud instances if (isCloud() && str_contains($e->getMessage(), 'domain is not verified')) { diff --git a/app/Notifications/Channels/TransactionalEmailChannel.php b/app/Notifications/Channels/TransactionalEmailChannel.php index 8ab74a60b..803db57f3 100644 --- a/app/Notifications/Channels/TransactionalEmailChannel.php +++ b/app/Notifications/Channels/TransactionalEmailChannel.php @@ -3,6 +3,7 @@ namespace App\Notifications\Channels; use App\Models\User; +use App\Services\NotificationDeduplicator; use Exception; use Illuminate\Mail\Message; use Illuminate\Notifications\Notification; @@ -10,6 +11,8 @@ class TransactionalEmailChannel { + public function __construct(private NotificationDeduplicator $deduplicator) {} + public function send(User $notifiable, Notification $notification): void { $settings = instanceSettings(); @@ -27,13 +30,19 @@ public function send(User $notifiable, Notification $notification): void } $this->bootConfigs(); $mailMessage = $notification->toMail($notifiable); + $renderedMail = (string) $mailMessage->render(); + + if (! $this->deduplicator->shouldSend($notifiable, $notification, self::class, [$email], $mailMessage->subject, $renderedMail)) { + return; + } + Mail::send( [], [], fn (Message $message) => $message ->to($email) ->subject($mailMessage->subject) - ->html((string) $mailMessage->render()) + ->html($renderedMail) ); } diff --git a/app/Notifications/Container/ContainerRestarted.php b/app/Notifications/Container/ContainerRestarted.php index 2d7eb58b5..d51c77cb3 100644 --- a/app/Notifications/Container/ContainerRestarted.php +++ b/app/Notifications/Container/ContainerRestarted.php @@ -21,6 +21,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('status_change'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "container-restarted:server:{$this->server->uuid}:container:{$this->name}"; + } + + public function deduplicateFor(): int + { + return 3600; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Container/ContainerStopped.php b/app/Notifications/Container/ContainerStopped.php index f518cd2fd..7daba04ca 100644 --- a/app/Notifications/Container/ContainerStopped.php +++ b/app/Notifications/Container/ContainerStopped.php @@ -21,6 +21,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('status_change'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "container-stopped:server:{$this->server->uuid}:container:{$this->name}"; + } + + public function deduplicateFor(): int + { + return 3600; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/CustomEmailNotification.php b/app/Notifications/CustomEmailNotification.php index c3c89b30f..e3f62e22a 100644 --- a/app/Notifications/CustomEmailNotification.php +++ b/app/Notifications/CustomEmailNotification.php @@ -15,4 +15,19 @@ class CustomEmailNotification extends Notification implements ShouldQueue public $tries = 5; public $maxExceptions = 5; + + public function shouldDeduplicate(): bool + { + return true; + } + + public function deduplicateFor(): int + { + return 900; + } + + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return null; + } } diff --git a/app/Notifications/Database/BackupFailed.php b/app/Notifications/Database/BackupFailed.php index c2b21b1d5..8d9c99603 100644 --- a/app/Notifications/Database/BackupFailed.php +++ b/app/Notifications/Database/BackupFailed.php @@ -11,6 +11,8 @@ class BackupFailed extends CustomEmailNotification { + public int|string|null $backupId = null; + public string $name; public string $frequency; @@ -18,6 +20,7 @@ class BackupFailed extends CustomEmailNotification public function __construct(ScheduledDatabaseBackup $backup, public $database, public $output, public $database_name) { $this->onQueue('high'); + $this->backupId = data_get($backup, 'uuid') ?? data_get($backup, 'id'); $this->name = $database->name; $this->frequency = $backup->frequency; } @@ -27,6 +30,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('backup_failure'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "backup-failed:backup:{$this->backupId}:database:{$this->database->uuid}:output:".hash('sha256', (string) $this->output); + } + + public function deduplicateFor(): int + { + return 21600; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Database/BackupSuccess.php b/app/Notifications/Database/BackupSuccess.php index 3d2d8ece3..166a48496 100644 --- a/app/Notifications/Database/BackupSuccess.php +++ b/app/Notifications/Database/BackupSuccess.php @@ -11,6 +11,8 @@ class BackupSuccess extends CustomEmailNotification { + public int|string|null $backupId = null; + public string $name; public string $frequency; @@ -18,6 +20,7 @@ class BackupSuccess extends CustomEmailNotification public function __construct(ScheduledDatabaseBackup $backup, public $database, public $database_name) { $this->onQueue('high'); + $this->backupId = data_get($backup, 'uuid') ?? data_get($backup, 'id'); $this->name = $database->name; $this->frequency = $backup->frequency; @@ -28,6 +31,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('backup_success'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "backup-success:backup:{$this->backupId}:database:{$this->database->uuid}:name:{$this->database_name}:frequency:{$this->frequency}"; + } + + public function deduplicateFor(): int + { + return 86400; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Database/BackupSuccessWithS3Warning.php b/app/Notifications/Database/BackupSuccessWithS3Warning.php index ee24ef17d..0da619448 100644 --- a/app/Notifications/Database/BackupSuccessWithS3Warning.php +++ b/app/Notifications/Database/BackupSuccessWithS3Warning.php @@ -11,6 +11,8 @@ class BackupSuccessWithS3Warning extends CustomEmailNotification { + public int|string|null $backupId = null; + public string $name; public string $frequency; @@ -20,6 +22,7 @@ class BackupSuccessWithS3Warning extends CustomEmailNotification public function __construct(ScheduledDatabaseBackup $backup, public $database, public $database_name, public $s3_error) { $this->onQueue('high'); + $this->backupId = data_get($backup, 'uuid') ?? data_get($backup, 'id'); $this->name = $database->name; $this->frequency = $backup->frequency; @@ -34,6 +37,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('backup_failure'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "backup-s3-warning:backup:{$this->backupId}:database:{$this->database->uuid}:error:".hash('sha256', (string) $this->s3_error); + } + + public function deduplicateFor(): int + { + return 21600; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/ScheduledTask/TaskFailed.php b/app/Notifications/ScheduledTask/TaskFailed.php index bd060112a..5078ca8e9 100644 --- a/app/Notifications/ScheduledTask/TaskFailed.php +++ b/app/Notifications/ScheduledTask/TaskFailed.php @@ -28,6 +28,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('scheduled_task_failure'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "scheduled-task-failed:task:{$this->task->uuid}:output:".hash('sha256', $this->output); + } + + public function deduplicateFor(): int + { + return 3600; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/ScheduledTask/TaskSuccess.php b/app/Notifications/ScheduledTask/TaskSuccess.php index 58c959bd8..0231ecf3d 100644 --- a/app/Notifications/ScheduledTask/TaskSuccess.php +++ b/app/Notifications/ScheduledTask/TaskSuccess.php @@ -28,6 +28,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('scheduled_task_success'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "scheduled-task-success:task:{$this->task->uuid}:output:".hash('sha256', $this->output); + } + + public function deduplicateFor(): int + { + return 3600; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/DockerCleanupFailed.php b/app/Notifications/Server/DockerCleanupFailed.php index 9cbdeb488..ac0eea17d 100644 --- a/app/Notifications/Server/DockerCleanupFailed.php +++ b/app/Notifications/Server/DockerCleanupFailed.php @@ -21,6 +21,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('docker_cleanup_failure'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "docker-cleanup-failed:server:{$this->server->uuid}:message:".hash('sha256', $this->message); + } + + public function deduplicateFor(): int + { + return 21600; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/DockerCleanupSuccess.php b/app/Notifications/Server/DockerCleanupSuccess.php index d28f25c6c..7e5ec0bcf 100644 --- a/app/Notifications/Server/DockerCleanupSuccess.php +++ b/app/Notifications/Server/DockerCleanupSuccess.php @@ -21,6 +21,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('docker_cleanup_success'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "docker-cleanup-success:server:{$this->server->uuid}:message:".hash('sha256', $this->message); + } + + public function deduplicateFor(): int + { + return 21600; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/ForceDisabled.php b/app/Notifications/Server/ForceDisabled.php index 4b56f5860..8d1817026 100644 --- a/app/Notifications/Server/ForceDisabled.php +++ b/app/Notifications/Server/ForceDisabled.php @@ -21,6 +21,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('server_force_disabled'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "server-force-disabled:{$this->server->uuid}"; + } + + public function deduplicateFor(): int + { + return 86400; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/ForceEnabled.php b/app/Notifications/Server/ForceEnabled.php index 36dad3c60..3db96f995 100644 --- a/app/Notifications/Server/ForceEnabled.php +++ b/app/Notifications/Server/ForceEnabled.php @@ -21,6 +21,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('server_force_enabled'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "server-force-enabled:{$this->server->uuid}"; + } + + public function deduplicateFor(): int + { + return 86400; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/HetznerDeletionFailed.php b/app/Notifications/Server/HetznerDeletionFailed.php index bb452b054..866d2eb07 100644 --- a/app/Notifications/Server/HetznerDeletionFailed.php +++ b/app/Notifications/Server/HetznerDeletionFailed.php @@ -21,6 +21,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('hetzner_deletion_failed'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "hetzner-deletion-failed:{$this->hetznerServerId}:error:".hash('sha256', $this->errorMessage); + } + + public function deduplicateFor(): int + { + return 86400; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/HighDiskUsage.php b/app/Notifications/Server/HighDiskUsage.php index 149d1bbc8..4007ca805 100644 --- a/app/Notifications/Server/HighDiskUsage.php +++ b/app/Notifications/Server/HighDiskUsage.php @@ -21,6 +21,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('server_disk_usage'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "high-disk-usage:server:{$this->server->uuid}:threshold:{$this->server_disk_usage_notification_threshold}"; + } + + public function deduplicateFor(): int + { + return 21600; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/Reachable.php b/app/Notifications/Server/Reachable.php index e64b0af2a..b297b7d3d 100644 --- a/app/Notifications/Server/Reachable.php +++ b/app/Notifications/Server/Reachable.php @@ -30,6 +30,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('server_reachable'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "server-reachable:{$this->server->uuid}"; + } + + public function deduplicateFor(): int + { + return 1800; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/ServerPatchCheck.php b/app/Notifications/Server/ServerPatchCheck.php index ba6cd4982..d0d5f4875 100644 --- a/app/Notifications/Server/ServerPatchCheck.php +++ b/app/Notifications/Server/ServerPatchCheck.php @@ -24,6 +24,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('server_patch'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "server-patch-check:server:{$this->server->uuid}:state:".hash('sha256', json_encode($this->patchData)); + } + + public function deduplicateFor(): int + { + return 86400; + } + public function toMail($notifiable = null): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/TraefikVersionOutdated.php b/app/Notifications/Server/TraefikVersionOutdated.php index c94cc1732..d6e5ae8aa 100644 --- a/app/Notifications/Server/TraefikVersionOutdated.php +++ b/app/Notifications/Server/TraefikVersionOutdated.php @@ -38,6 +38,18 @@ private function getUpgradeTarget(array $info): string return $this->formatVersion($info['latest'] ?? 'unknown'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + $serverUuids = $this->servers->pluck('uuid')->sort()->values()->join('|'); + + return 'traefik-version-outdated:servers:'.hash('sha256', $serverUuids); + } + + public function deduplicateFor(): int + { + return 86400; + } + public function toMail($notifiable = null): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Server/Unreachable.php b/app/Notifications/Server/Unreachable.php index 99742f3b7..cd6fd63b6 100644 --- a/app/Notifications/Server/Unreachable.php +++ b/app/Notifications/Server/Unreachable.php @@ -30,6 +30,16 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('server_unreachable'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "server-unreachable:{$this->server->uuid}"; + } + + public function deduplicateFor(): int + { + return 3600; + } + public function toMail(): ?MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/SslExpirationNotification.php b/app/Notifications/SslExpirationNotification.php index 78e1e8be9..72ce136bd 100644 --- a/app/Notifications/SslExpirationNotification.php +++ b/app/Notifications/SslExpirationNotification.php @@ -59,6 +59,22 @@ public function via(object $notifiable): array return $notifiable->getEnabledChannels('ssl_certificate_renewal'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + $resourceKeys = $this->resources + ->map(fn ($resource) => data_get($resource, 'uuid') ?? data_get($resource, 'name')) + ->sort() + ->values() + ->join('|'); + + return 'ssl-certificate-renewed:resources:'.hash('sha256', $resourceKeys); + } + + public function deduplicateFor(): int + { + return 86400; + } + public function toMail(): MailMessage { $mail = new MailMessage; diff --git a/app/Notifications/Test.php b/app/Notifications/Test.php index bbed22777..ea3dfe9c1 100644 --- a/app/Notifications/Test.php +++ b/app/Notifications/Test.php @@ -30,6 +30,11 @@ public function __construct(public ?string $emails = null, public ?string $chann $this->onQueue('high'); } + public function shouldDeduplicate(): bool + { + return false; + } + public function via(object $notifiable): array { if ($this->channel) { diff --git a/app/Notifications/TransactionalEmails/EmailChangeVerification.php b/app/Notifications/TransactionalEmails/EmailChangeVerification.php index ea8462366..bb5e7f870 100644 --- a/app/Notifications/TransactionalEmails/EmailChangeVerification.php +++ b/app/Notifications/TransactionalEmails/EmailChangeVerification.php @@ -25,6 +25,16 @@ public function __construct( $this->onQueue('high'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "email-change-verification:user:{$this->user->id}:email:{$this->newEmail}:code:{$this->verificationCode}"; + } + + public function deduplicateFor(): int + { + return (int) max(1, now()->diffInSeconds($this->expiresAt, false)); + } + public function toMail(): MailMessage { // Use the configured expiry minutes value diff --git a/app/Notifications/TransactionalEmails/InvitationLink.php b/app/Notifications/TransactionalEmails/InvitationLink.php index 9bfb54798..f3b1e6d67 100644 --- a/app/Notifications/TransactionalEmails/InvitationLink.php +++ b/app/Notifications/TransactionalEmails/InvitationLink.php @@ -21,6 +21,16 @@ public function __construct(public User $user, public bool $isTransactionalEmail $this->onQueue('high'); } + public function deduplicationKey(object $notifiable, string $channel): ?string + { + return "invitation-link:user:{$this->user->id}:email:{$this->user->email}"; + } + + public function deduplicateFor(): int + { + return 3600; + } + public function toMail(): MailMessage { $invitation = TeamInvitation::whereEmail($this->user->email)->first(); diff --git a/app/Notifications/TransactionalEmails/Test.php b/app/Notifications/TransactionalEmails/Test.php index 2f7d70bbf..dc8c0dac7 100644 --- a/app/Notifications/TransactionalEmails/Test.php +++ b/app/Notifications/TransactionalEmails/Test.php @@ -15,6 +15,11 @@ public function __construct(public string $emails, public bool $isTransactionalE $this->onQueue('high'); } + public function shouldDeduplicate(): bool + { + return false; + } + public function via(): array { return [EmailChannel::class]; diff --git a/app/Services/NotificationDeduplicator.php b/app/Services/NotificationDeduplicator.php new file mode 100644 index 000000000..d018dd0a5 --- /dev/null +++ b/app/Services/NotificationDeduplicator.php @@ -0,0 +1,95 @@ + $recipients + */ + public function shouldSend(object $notifiable, Notification $notification, string $channel, array $recipients, ?string $subject = null, ?string $body = null): bool + { + if (method_exists($notification, 'shouldDeduplicate') && ! $notification->shouldDeduplicate()) { + return true; + } + + $ttl = method_exists($notification, 'deduplicateFor') + ? $notification->deduplicateFor() + : self::DEFAULT_TTL; + + if ($ttl <= 0) { + return true; + } + + return Cache::add( + $this->cacheKey($notifiable, $notification, $channel, $recipients, $subject, $body), + true, + $ttl, + ); + } + + /** + * @param array $recipients + */ + private function cacheKey(object $notifiable, Notification $notification, string $channel, array $recipients, ?string $subject, ?string $body): string + { + $semanticKey = method_exists($notification, 'deduplicationKey') + ? $notification->deduplicationKey($notifiable, $channel) + : null; + + $payload = $semanticKey + ? $this->semanticFingerprint($notifiable, $notification, $channel, $recipients, $semanticKey) + : $this->defaultFingerprint($notifiable, $notification, $channel, $recipients, $subject, $body); + + return 'notification-dedupe:'.hash('sha256', $payload); + } + + /** + * @param array $recipients + */ + private function semanticFingerprint(object $notifiable, Notification $notification, string $channel, array $recipients, string $semanticKey): string + { + return json_encode([ + 'notification' => $notification::class, + 'notifiable' => $notifiable::class, + 'notifiable_id' => data_get($notifiable, 'id'), + 'channel' => $channel, + 'recipients' => $this->normalizeRecipients($recipients), + 'semantic_key' => $semanticKey, + ], JSON_THROW_ON_ERROR); + } + + /** + * @param array $recipients + */ + private function defaultFingerprint(object $notifiable, Notification $notification, string $channel, array $recipients, ?string $subject, ?string $body): string + { + return json_encode([ + 'notification' => $notification::class, + 'notifiable' => $notifiable::class, + 'notifiable_id' => data_get($notifiable, 'id'), + 'channel' => $channel, + 'recipients' => $this->normalizeRecipients($recipients), + 'subject' => $subject, + 'body_hash' => hash('sha256', (string) $body), + ], JSON_THROW_ON_ERROR); + } + + /** + * @param array $recipients + * @return array + */ + private function normalizeRecipients(array $recipients): array + { + return collect($recipients) + ->map(fn (string $recipient) => mb_strtolower(trim($recipient))) + ->sort() + ->values() + ->all(); + } +} diff --git a/tests/Feature/ApplicationStoppedAfterRestartLimitTest.php b/tests/Feature/ApplicationStoppedAfterRestartLimitTest.php index 6b82d5568..482a3e16e 100644 --- a/tests/Feature/ApplicationStoppedAfterRestartLimitTest.php +++ b/tests/Feature/ApplicationStoppedAfterRestartLimitTest.php @@ -48,6 +48,24 @@ function applicationWithRestartState(array $attributes = []): Application 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'); diff --git a/tests/Feature/NotificationDeduplicationTest.php b/tests/Feature/NotificationDeduplicationTest.php new file mode 100644 index 000000000..39fc70433 --- /dev/null +++ b/tests/Feature/NotificationDeduplicationTest.php @@ -0,0 +1,75 @@ +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', '

Body

'))->toBeTrue() + ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', '

Body

'))->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', '

Body

'))->toBeTrue() + ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['second@example.com'], 'Subject', '

Body

'))->toBeTrue() + ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Other subject', '

Body

'))->toBeTrue() + ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', '

Other body

'))->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', '

Body

'))->toBeTrue() + ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Other subject', '

Other body

'))->toBeFalse() + ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['second@example.com'], 'Other subject', '

Other body

'))->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', '

Body

'))->toBeTrue() + ->and($this->deduplicator->shouldSend($this->notifiable, $notification, 'mail', ['first@example.com'], 'Subject', '

Body

'))->toBeTrue(); +});