From 54fca421d6cca5851bc59b1c074bfc48c1862168 Mon Sep 17 00:00:00 2001 From: "Florent C." Date: Wed, 22 Apr 2026 18:24:12 +0200 Subject: [PATCH] feat: Email notifications - control panel in subscriptions page to opt in and select mail address - simplification of pgpubsub channels (now notification in sequence with caching) - emails mention affected packages, matching packages, and useful links - 1-1 correspondance between email and notification --- nix/configuration.nix | 3 +- src/shared/channels.py | 8 +- src/shared/listeners/__init__.py | 1 + src/shared/listeners/cache_and_notify.py | 30 +++++ src/shared/listeners/cache_suggestions.py | 16 --- src/shared/listeners/notify_users.py | 127 ++++++++++++++++-- ...clusterproposal_pgpubsub_07e32_and_more.py | 27 ++++ ...009_profile_notification_email_and_more.py | 23 ++++ src/webview/models.py | 23 +++- src/webview/subscriptions/urls.py | 12 ++ src/webview/subscriptions/views.py | 83 ++++++++++++ .../email_notifications_toggler.html | 26 ++++ .../components/email_setter.html | 60 +++++++++ .../subscriptions/subscriptions_center.html | 8 ++ src/webview/templatetags/viewutils.py | 31 ++++- src/webview/tests/test_subscriptions.py | 60 +++++++++ 16 files changed, 492 insertions(+), 46 deletions(-) create mode 100644 src/shared/listeners/cache_and_notify.py create mode 100644 src/shared/migrations/0083_remove_cvederivationclusterproposal_pgpubsub_07e32_and_more.py create mode 100644 src/webview/migrations/0009_profile_notification_email_and_more.py create mode 100644 src/webview/templates/subscriptions/components/email_notifications_toggler.html create mode 100644 src/webview/templates/subscriptions/components/email_setter.html diff --git a/nix/configuration.nix b/nix/configuration.nix index fc631359a..657f39082 100644 --- a/nix/configuration.nix +++ b/nix/configuration.nix @@ -357,8 +357,7 @@ in shared.channels.NixChannelInsertChannel \ shared.channels.NixChannelUpdateChannel \ shared.channels.ContainerChannel \ - shared.channels.CVEDerivationClusterProposalCacheChannel \ - shared.channels.CVEDerivationClusterProposalNotificationChannel \ + shared.channels.CVEDerivationClusterProposalChannel \ ''; }; diff --git a/src/shared/channels.py b/src/shared/channels.py index 701c7b493..9f0f3af50 100644 --- a/src/shared/channels.py +++ b/src/shared/channels.py @@ -44,13 +44,7 @@ class ContainerChannel(TriggerChannel): @dataclass -class CVEDerivationClusterProposalCacheChannel(TriggerChannel): - model = CVEDerivationClusterProposal - lock_notifications = True - - -@dataclass -class CVEDerivationClusterProposalNotificationChannel(TriggerChannel): +class CVEDerivationClusterProposalChannel(TriggerChannel): model = CVEDerivationClusterProposal lock_notifications = True diff --git a/src/shared/listeners/__init__.py b/src/shared/listeners/__init__.py index 481c01f2b..6482e17db 100644 --- a/src/shared/listeners/__init__.py +++ b/src/shared/listeners/__init__.py @@ -3,3 +3,4 @@ import shared.listeners.automatic_linkage # noqa import shared.listeners.cache_suggestions # noqa import shared.listeners.notify_users # noqa +import shared.listeners.cache_and_notify # noqa diff --git a/src/shared/listeners/cache_and_notify.py b/src/shared/listeners/cache_and_notify.py new file mode 100644 index 000000000..cb0eee728 --- /dev/null +++ b/src/shared/listeners/cache_and_notify.py @@ -0,0 +1,30 @@ +import logging + +import pgpubsub + +from shared.channels import CVEDerivationClusterProposalChannel +from shared.listeners.cache_suggestions import cache_new_suggestions +from shared.listeners.notify_users import create_package_subscription_notifications +from shared.models.linkage import CVEDerivationClusterProposal + +logger = logging.getLogger(__name__) + +# FIXME: this breaks the insert listener, let's report it upstream. +# @pgpubsub.post_update_listener(CVEDerivationClusterProposalChannel) +# def expire_cached_suggestions(old: CVEDerivationClusterProposal, new: CVEDerivationClusterProposal) -> None: +# if new.status != CVEDerivationClusterProposal.Status.PENDING: +# CachedSuggestions.objects.filter(pk=new.pk).delete() + + +@pgpubsub.post_insert_listener(CVEDerivationClusterProposalChannel) +def cache_new_suggestions_following_new_container( + old: CVEDerivationClusterProposal, new: CVEDerivationClusterProposal +) -> None: + logger.info(f"Cache and notify for suggestion {new.pk}") + cache_new_suggestions(new) + try: + create_package_subscription_notifications(new) + except Exception as e: + logger.error( + f"Failed to create package subscription notifications for suggestion {new.pk}: {e}" + ) diff --git a/src/shared/listeners/cache_suggestions.py b/src/shared/listeners/cache_suggestions.py index 5497e4b11..7b7f1cd38 100644 --- a/src/shared/listeners/cache_suggestions.py +++ b/src/shared/listeners/cache_suggestions.py @@ -7,11 +7,9 @@ from itertools import chain from typing import Any, overload -import pgpubsub from django.db.models import Prefetch, Q from pydantic import BaseModel, field_serializer -from shared.channels import CVEDerivationClusterProposalCacheChannel from shared.models import NixDerivation, NixMaintainer from shared.models.cached import CachedSuggestions from shared.models.cve import AffectedProduct, Metric, Reference, Version @@ -263,20 +261,6 @@ def cache_new_suggestions(suggestion: CVEDerivationClusterProposal) -> None: logger.info("CVE '%s' suggestion cache updated", suggestion.cve.cve_id) -# FIXME: this breaks the insert listener, let's report it upstream. -# @pgpubsub.post_update_listener(CVEDerivationClusterProposalChannel) -# def expire_cached_suggestions(old: CVEDerivationClusterProposal, new: CVEDerivationClusterProposal) -> None: -# if new.status != CVEDerivationClusterProposal.Status.PENDING: -# CachedSuggestions.objects.filter(pk=new.pk).delete() - - -@pgpubsub.post_insert_listener(CVEDerivationClusterProposalCacheChannel) -def cache_new_suggestions_following_new_container( - old: CVEDerivationClusterProposal, new: CVEDerivationClusterProposal -) -> None: - cache_new_suggestions(new) - - def is_version_affected(version_statuses: list[str]) -> Version.Status: """ Returns the highest priority status from the list of version constraints. diff --git a/src/shared/listeners/notify_users.py b/src/shared/listeners/notify_users.py index dcea39b27..7292dd8cd 100644 --- a/src/shared/listeners/notify_users.py +++ b/src/shared/listeners/notify_users.py @@ -1,12 +1,15 @@ import logging +from urllib.parse import urljoin -import pgpubsub +from django.conf import settings from django.contrib.auth.models import User +from django.core.mail import send_mail +from django.urls import reverse -from shared.channels import CVEDerivationClusterProposalNotificationChannel from shared.models.linkage import CVEDerivationClusterProposal from webview.models import Profile from webview.models import SuggestionNotification as Notification +from webview.notifications.context import NotificationContext logger = logging.getLogger(__name__) @@ -19,6 +22,10 @@ def create_package_subscription_notifications( and for maintainers of those packages (if they have auto-subscribe enabled). """ + # FIXME(@florentc): These queries are no longer related. We should use the + # cached suggestions when possible. This was done back when notifying could + # happen before caching. + # Query package attributes directly from the suggestion's derivations affected_packages = list( suggestion.derivations.values_list("attribute", flat=True).distinct() @@ -77,21 +84,113 @@ def create_package_subscription_notifications( ) notifications.append(notification) logger.debug(f"Created notification for user {user.username}") + + # Send email notification if enabled + try: + send_notification_email(user, notification) + except Exception as e: + logger.error( + f"Failed to send email notification to user {user.username}: {e}" + ) + except Exception as e: logger.error(f"Failed to create notification for user {user.username}: {e}") return notifications -@pgpubsub.post_insert_listener(CVEDerivationClusterProposalNotificationChannel) -def notify_subscribed_users_following_suggestion_insert( - old: CVEDerivationClusterProposal, new: CVEDerivationClusterProposal -) -> None: - """ - Notify users subscribed to packages when a new security suggestion is created. - """ - try: - create_package_subscription_notifications(new) - except Exception as e: - logger.error( - f"Failed to create package subscription notifications for suggestion {new.pk}: {e}" +def send_notification_email(user: User, notification: Notification) -> None: + """Send an email notification to a user about a new CVE suggestion.""" + if not (user.profile.receive_email_notifications): + return + + email = ( + user.profile.notification_email + if user.profile.notification_email + else user.profile.github_email + ) + + if not email: + logger.info(f"Could not send email notification to {user.username}: no address") + return + + # Reuse existing NotificationContext logic for package matching + context = NotificationContext(notification=notification, user_profile=user.profile) + + suggestion = notification.suggestion + subject = f"New security notification: {suggestion.cve.cve_id}" + + message_parts = [ + "Hello,", + "", + "A new security vulnerability has been identified that may affect packages you follow or maintain:", + "", + f"CVE: {suggestion.cve.cve_id}", + f"Details: https://nvd.nist.gov/vuln/detail/{suggestion.cve.cve_id}", + "", + ] + + if suggestion.cached and suggestion.cached.payload.get("affected_products"): + affected_products = suggestion.cached.payload["affected_products"] + if affected_products: + message_parts.extend( + [ + "Affected products:", + ] + ) + for package_name, ap in affected_products.items(): + version_info = [] + for status, vc_str in ap.get("version_constraints", []): + if status == "unaffected": + version_info.append(f"{vc_str} (unaffected)") + else: + version_info.append(vc_str) + + if version_info: + message_parts.append( + f" - {package_name}: {', '.join(version_info)}" + ) + else: + message_parts.append(f" - {package_name}") + + message_parts.append("") + + if context.matching_subscribed_packages: + message_parts.extend( + [ + "Packages you follow that may be affected:", + *[f" - {pkg}" for pkg in context.matching_subscribed_packages.keys()], + "", + ] + ) + + if context.matching_maintained_packages: + message_parts.extend( + [ + "Packages you maintain that may be affected:", + *[f" - {pkg}" for pkg in context.matching_maintained_packages.keys()], + "", + ] ) + + message_parts.extend( + [ + f"View all notifications: {urljoin(str(settings.BASE_URL), reverse('webview:notifications:center'))}", + f"Review suggestion details: {urljoin(str(settings.BASE_URL), reverse('webview:suggestion:detail', kwargs={'suggestion_id': suggestion.pk}))}", + "", + "Best regards,", + "Nix Security Tracker", + "", + f"Manage email notification preferences: {urljoin(str(settings.BASE_URL), reverse('webview:subscriptions:center'))}", + ] + ) + + message = "\n".join(message_parts) + + send_mail( + subject=subject, + message=message, + from_email=None, # Use default + recipient_list=[email], + fail_silently=False, + ) + logger.info(f"Email notification sent to {user.username} at {email}") diff --git a/src/shared/migrations/0083_remove_cvederivationclusterproposal_pgpubsub_07e32_and_more.py b/src/shared/migrations/0083_remove_cvederivationclusterproposal_pgpubsub_07e32_and_more.py new file mode 100644 index 000000000..edc3e9d04 --- /dev/null +++ b/src/shared/migrations/0083_remove_cvederivationclusterproposal_pgpubsub_07e32_and_more.py @@ -0,0 +1,27 @@ +# Generated by Django 5.2.9 on 2026-04-29 11:16 + +import pgtrigger.compiler +import pgtrigger.migrations +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('shared', '0082_alter_derivationclusterproposallink_derivation_and_more'), + ] + + operations = [ + pgtrigger.migrations.RemoveTrigger( + model_name='cvederivationclusterproposal', + name='pgpubsub_07e32', + ), + pgtrigger.migrations.RemoveTrigger( + model_name='cvederivationclusterproposal', + name='pgpubsub_6aede', + ), + pgtrigger.migrations.AddTrigger( + model_name='cvederivationclusterproposal', + trigger=pgtrigger.compiler.Trigger(name='pgpubsub_8c7ef', sql=pgtrigger.compiler.UpsertTriggerSql(declare='DECLARE payload JSONB; notification_context_text TEXT;', func='\n \n payload := \'{"app": "shared", "model": "CVEDerivationClusterProposal"}\'::jsonb;\n payload := jsonb_insert(payload, \'{old}\', COALESCE(to_jsonb(OLD), \'null\'));\n payload := jsonb_insert(payload, \'{new}\', COALESCE(to_jsonb(NEW), \'null\'));\n SELECT current_setting(\'pgpubsub.notification_context\', True) INTO notification_context_text;\n IF COALESCE(notification_context_text, \'\') = \'\' THEN\n notification_context_text := \'{}\';\n END IF;\n payload := jsonb_insert(payload, \'{context}\', notification_context_text::jsonb);\n \n \n INSERT INTO pgpubsub_notification (channel, payload)\n VALUES (\'pgpubsub_8c7ef\', payload);\n \n perform pg_notify(\'pgpubsub_8c7ef\', payload::text);\n RETURN NEW;\n ', hash='f992274d71c90db51227a6220854697b1f77b60e', operation='INSERT', pgid='pgtrigger_pgpubsub_8c7ef_29d5d', table='shared_cvederivationclusterproposal', when='AFTER')), + ), + ] diff --git a/src/webview/migrations/0009_profile_notification_email_and_more.py b/src/webview/migrations/0009_profile_notification_email_and_more.py new file mode 100644 index 000000000..a5526c5e0 --- /dev/null +++ b/src/webview/migrations/0009_profile_notification_email_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.9 on 2026-04-22 13:35 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('webview', '0008_alter_suggestionnotification_suggestion'), + ] + + operations = [ + migrations.AddField( + model_name='profile', + name='notification_email', + field=models.EmailField(blank=True, help_text="Email address to use for notifications in case it's different from the GitHub email", max_length=254), + ), + migrations.AddField( + model_name='profile', + name='receive_email_notifications', + field=models.BooleanField(default=False, help_text='Receive an email about new notifications'), + ), + ] diff --git a/src/webview/models.py b/src/webview/models.py index 8fca0ad06..c3d5ab5d6 100644 --- a/src/webview/models.py +++ b/src/webview/models.py @@ -9,6 +9,7 @@ from shared.models import TimeStampMixin from shared.models.linkage import CVEDerivationClusterProposal +from shared.models.nix_evaluation import NixMaintainer class Notification(TimeStampMixin): @@ -92,6 +93,27 @@ class Profile(models.Model): default=True, help_text="Automatically subscribe to notifications for packages this user maintains", ) + notification_email = models.EmailField( + blank=True, + help_text="Email address to use for notifications in case it's different from the GitHub email", + ) + receive_email_notifications = models.BooleanField( + default=False, + help_text="Receive an email about new notifications", + ) + + @property + def github_email(self) -> str | None: + try: + maintainer = NixMaintainer.objects.get( + github_id=self.user.socialaccount_set.get(provider="github").uid + ) + return maintainer.email + except ( + NixMaintainer.DoesNotExist, + self.user.socialaccount_set.model.DoesNotExist, + ): + return None def create_notification( self, suggestion: CVEDerivationClusterProposal @@ -101,7 +123,6 @@ def create_notification( user=self.user, suggestion=suggestion, ) - self.unread_notifications_count += 1 self.save(update_fields=["unread_notifications_count"]) diff --git a/src/webview/subscriptions/urls.py b/src/webview/subscriptions/urls.py index ebbf53647..767ae28a9 100644 --- a/src/webview/subscriptions/urls.py +++ b/src/webview/subscriptions/urls.py @@ -4,8 +4,10 @@ AddSubscriptionView, PackageSubscriptionView, RemoveSubscriptionView, + SetNotificationEmailView, SubscriptionCenterView, ToggleAutoSubscribeView, + ToggleReceiveEmailNotificationsView, ) app_name = "subscriptions" @@ -19,6 +21,16 @@ ToggleAutoSubscribeView.as_view(), name="toggle_auto_subscribe", ), + path( + "toggle-receive-email-notifications/", + ToggleReceiveEmailNotificationsView.as_view(), + name="toggle_receive_email_notifications", + ), + path( + "set-email/", + SetNotificationEmailView.as_view(), + name="set_email", + ), path( "package//", PackageSubscriptionView.as_view(), name="package" ), diff --git a/src/webview/subscriptions/views.py b/src/webview/subscriptions/views.py index fc2cfe179..c68cea5e1 100644 --- a/src/webview/subscriptions/views.py +++ b/src/webview/subscriptions/views.py @@ -181,6 +181,89 @@ def _handle_error(self, request: HttpRequest, error_message: str) -> HttpRespons return redirect(reverse("webview:subscriptions:center")) +class ToggleReceiveEmailNotificationsView(LoginRequiredMixin, TemplateView): + """Toggle receiving emails for new notifications.""" + + template_name = "subscriptions/components/email_notifications_toggler.html" + + def post(self, request: HttpRequest) -> HttpResponse: + """Toggle auto-subscription setting.""" + action = request.POST.get("action", "") + + if action not in ["enable", "disable"]: + return self._handle_error(request, "Invalid action.") + + profile = request.user.profile + + profile.receive_email_notifications = action == "enable" + + profile.save(update_fields=["receive_email_notifications"]) + + # Handle HTMX vs standard request + if request.headers.get("HX-Request"): + return self.render_to_response( + { + "enabled": profile.receive_email_notifications, + } + ) + else: + return redirect(reverse("webview:subscriptions:center")) + + def _handle_error(self, request: HttpRequest, error_message: str) -> HttpResponse: + """Handle error responses for both HTMX and standard requests.""" + if request.headers.get("HX-Request"): + return self.render_to_response( + { + "auto_subscribe_enabled": request.user.profile.receive_email_notifications, + "error_message": error_message, + } + ) + else: + # Without javascript, we use Django messages for the errors + messages.error(request, error_message) + return redirect(reverse("webview:subscriptions:center")) + + +class SetNotificationEmailView(LoginRequiredMixin, TemplateView): + """Set email used for notifications""" + + template_name = "subscriptions/components/email_setter.html" + + def post(self, request: HttpRequest) -> HttpResponse: + """Toggle auto-subscription setting.""" + email = request.POST.get("email", "") + + profile = request.user.profile + profile.notification_email = email + profile.save(update_fields=["notification_email"]) + + # Handle HTMX vs standard request + if request.headers.get("HX-Request"): + return self.render_to_response( + { + "notification_email": profile.notification_email, + "github_email": request.user.profile.github_email, + } + ) + else: + return redirect(reverse("webview:subscriptions:center")) + + def _handle_error(self, request: HttpRequest, error_message: str) -> HttpResponse: + """Handle error responses for both HTMX and standard requests.""" + if request.headers.get("HX-Request"): + return self.render_to_response( + { + "notification_email": request.user.profile.notification_email, + "github_email": request.user.profile.github_email, + "error_message": error_message, + } + ) + else: + # Without javascript, we use Django messages for the errors + messages.error(request, error_message) + return redirect(reverse("webview:subscriptions:center")) + + class PackageSubscriptionView(LoginRequiredMixin, TemplateView): """Display a package subscription page for a specific package.""" diff --git a/src/webview/templates/subscriptions/components/email_notifications_toggler.html b/src/webview/templates/subscriptions/components/email_notifications_toggler.html new file mode 100644 index 000000000..47da82c0e --- /dev/null +++ b/src/webview/templates/subscriptions/components/email_notifications_toggler.html @@ -0,0 +1,26 @@ +{% load viewutils %} + + diff --git a/src/webview/templates/subscriptions/components/email_setter.html b/src/webview/templates/subscriptions/components/email_setter.html new file mode 100644 index 000000000..458ff8147 --- /dev/null +++ b/src/webview/templates/subscriptions/components/email_setter.html @@ -0,0 +1,60 @@ +{% load viewutils %} + + +{% if error_message %}
{{ error_message }}
{% endif %} + diff --git a/src/webview/templates/subscriptions/subscriptions_center.html b/src/webview/templates/subscriptions/subscriptions_center.html index 09b9988d1..5c7e1ff2f 100644 --- a/src/webview/templates/subscriptions/subscriptions_center.html +++ b/src/webview/templates/subscriptions/subscriptions_center.html @@ -25,6 +25,14 @@

Additional packages

{% package_subscriptions package_subscriptions %} +
+

Email notifications

+ + {% email_notifications_toggler user.profile.receive_email_notifications %} + + {% email_setter notification_email=user.profile.notification_email github_email=user.profile.github_email %} +
+ {% endblock content %} diff --git a/src/webview/templatetags/viewutils.py b/src/webview/templatetags/viewutils.py index da15dff64..474b0338e 100644 --- a/src/webview/templatetags/viewutils.py +++ b/src/webview/templatetags/viewutils.py @@ -68,11 +68,6 @@ class PackageSubscriptionsContext(TypedDict): error_message: str | None -class AutoSubscribeContext(TypedDict): - auto_subscribe_enabled: bool - error_message: str | None - - @register.inclusion_tag("subscriptions/components/packages.html") def package_subscriptions( package_subscriptions: list[str], @@ -88,13 +83,37 @@ def package_subscriptions( def auto_subscribe_toggle( auto_subscribe_enabled: bool, error_message: str | None = None, -) -> AutoSubscribeContext: +) -> dict: return { "auto_subscribe_enabled": auto_subscribe_enabled, "error_message": error_message, } +@register.inclusion_tag("subscriptions/components/email_notifications_toggler.html") +def email_notifications_toggler( + enabled: bool, + error_message: str | None = None, +) -> dict: + return { + "enabled": enabled, + "error_message": error_message, + } + + +@register.inclusion_tag("subscriptions/components/email_setter.html") +def email_setter( + notification_email: str, + github_email: str, + error_message: str | None = None, +) -> dict: + return { + "notification_email": notification_email, + "github_email": github_email, + "error_message": error_message, + } + + @register.inclusion_tag("notifications/components/notification.html") def notification( data: NotificationContext, diff --git a/src/webview/tests/test_subscriptions.py b/src/webview/tests/test_subscriptions.py index 382ecca5f..bf829832d 100644 --- a/src/webview/tests/test_subscriptions.py +++ b/src/webview/tests/test_subscriptions.py @@ -5,6 +5,7 @@ from django.urls import reverse from playwright.sync_api import Page, expect from pytest_django.live_server_helper import LiveServer +from pytest_mock import MockerFixture from shared.listeners.notify_users import create_package_subscription_notifications from shared.models.linkage import CVEDerivationClusterProposal, ProvenanceFlags @@ -240,3 +241,62 @@ def test_maintainer_notification_many_packages_in_suggestion( notification = Notification.objects.first() assert notification assert notification.user == user + + +def test_email_notifications( + live_server: LiveServer, + staff: User, + as_staff: Page, + make_maintainer_notification: Callable[..., list[Notification]], + mocker: MockerFixture, +) -> None: + """ + Check that email notifications are sent according to personal user settings + """ + email_address = "alice@company.com" + as_staff.goto(live_server.url + reverse("webview:subscriptions:center")) + email_settings = as_staff.locator("#email-notifications") + # By default, it's supposed to be turned off + expect( + email_settings.get_by_text( + "You won't receive any emails for new notifications." + ) + ).to_be_visible() + expect(email_settings.get_by_text("No notification email set")).to_be_visible() + # Set a notification email address + email_settings.get_by_placeholder("Notification email").fill(email_address) + email_settings.get_by_role("button", name="Set email").click() + expect(email_settings.get_by_text("Current notification email:")).to_be_visible() + expect(email_settings.get_by_text(email_address)).to_be_visible() + # Ensure maintainers notifications are enabled + expect( + as_staff.locator("#maintainer-auto-subscription").get_by_text( + "You will automatically receive notifications about packages you maintain." + ) + ).to_be_visible() + # Generate notification and check that the user is not notified + mock_send_mail = mocker.patch("shared.listeners.notify_users.send_mail") + make_maintainer_notification(staff) + mock_send_mail.assert_not_called() + # Enable email notifications + email_settings.get_by_role("button", name="Enable").click() + email_settings.get_by_text( + "You will automatically receive emails for new notifications." + ).wait_for() + # Send a notification + mock_send_mail = mocker.patch("shared.listeners.notify_users.send_mail") + # Check email emission and content + [notification] = make_maintainer_notification(staff) + [package_name] = notification.suggestion.cached.payload.get("packages").keys() + mock_send_mail.assert_called_once() + recipient_list = mock_send_mail.call_args[1]["recipient_list"] + message = mock_send_mail.call_args[1]["message"] + assert recipient_list == [email_address] + assert f"Packages you maintain that may be affected:\n - {package_name}" in message + assert ( + reverse( + "webview:suggestion:detail", + kwargs={"suggestion_id": notification.suggestion.pk}, + ) + in message + )