feat(notifications): deduplicate repeated email alerts

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

No files matched your search

+25 -13
View File
@@ -4,13 +4,20 @@ namespace App\Notifications\Channels;
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 @@ class EmailChannel
}
$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 @@ class EmailChannel
'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 @@ class EmailChannel
$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 @@ class EmailChannel
// 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 @@ use Illuminate\Support\Facades\Mail;
class TransactionalEmailChannel
{
public function __construct(private NotificationDeduplicator $deduplicator) {}
public function send(User $notifiable, Notification $notification): void
{
$settings = instanceSettings();
@@ -27,13 +30,19 @@ class TransactionalEmailChannel
}
$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)
);
}