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 2026-07-08 15:23:14 +02:00
parent ee620a5413
commit 8c1405e168
33 changed files with 516 additions and 14 deletions

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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,
]);

View file

@ -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')) {

View file

@ -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)
);
}

View file

@ -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;

View file

@ -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;

View file

@ -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;
}
}

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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;

View file

@ -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) {

View file

@ -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

View file

@ -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();

View file

@ -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];

View 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();
}
}

View file

@ -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');

View 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();
});