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:
parent
ee620a5413
commit
8c1405e168
33 changed files with 516 additions and 14 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
]);
|
||||
|
||||
|
|
|
|||
|
|
@ -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')) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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];
|
||||
|
|
|
|||
95
app/Services/NotificationDeduplicator.php
Normal file
95
app/Services/NotificationDeduplicator.php
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<?php
|
||||
|
||||
namespace App\Services;
|
||||
|
||||
use Illuminate\Notifications\Notification;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
|
||||
class NotificationDeduplicator
|
||||
{
|
||||
public const DEFAULT_TTL = 900;
|
||||
|
||||
/**
|
||||
* @param array<int, string> $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<int, string> $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<int, string> $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<int, string> $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<int, string> $recipients
|
||||
* @return array<int, string>
|
||||
*/
|
||||
private function normalizeRecipients(array $recipients): array
|
||||
{
|
||||
return collect($recipients)
|
||||
->map(fn (string $recipient) => mb_strtolower(trim($recipient)))
|
||||
->sort()
|
||||
->values()
|
||||
->all();
|
||||
}
|
||||
}
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
75
tests/Feature/NotificationDeduplicationTest.php
Normal file
75
tests/Feature/NotificationDeduplicationTest.php
Normal file
|
|
@ -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();
|
||||
});
|
||||
Loading…
Reference in a new issue