From dd4e28ad3694a41647d7817676642b8c5526c13b Mon Sep 17 00:00:00 2001 From: Jumana Bahrainwala Date: Thu, 15 Jan 2026 16:44:36 +0000 Subject: [PATCH 1/5] added supression list feature --- app/main/forms.py | 7 + app/main/views/service_settings.py | 36 ++++ app/notify_client/service_api_client.py | 17 ++ app/templates/views/service-settings.html | 15 ++ .../service-settings/suppression-list.html | 42 ++++ .../service_settings/test_suppression_list.py | 195 ++++++++++++++++++ 6 files changed, 312 insertions(+) create mode 100644 app/templates/views/service-settings/suppression-list.html create mode 100644 tests/app/main/views/service_settings/test_suppression_list.py diff --git a/app/main/forms.py b/app/main/forms.py index 8037c64693..cc20f147c3 100644 --- a/app/main/forms.py +++ b/app/main/forms.py @@ -1627,6 +1627,13 @@ class ServiceDataRetentionEditForm(StripWhitespaceForm): ) +class SuppressionListRemovalForm(StripWhitespaceForm): + email_address = email_address( + _l("Email address"), + gov_user=False, + ) + + class ReturnedLettersForm(StripWhitespaceForm): references = TextAreaField( "Letter references", diff --git a/app/main/views/service_settings.py b/app/main/views/service_settings.py index 092f4a2715..83917cfbf9 100644 --- a/app/main/views/service_settings.py +++ b/app/main/views/service_settings.py @@ -62,6 +62,7 @@ SMSAnnualMessageLimit, SMSMessageLimit, SMSPrefixForm, + SuppressionListRemovalForm, ) from app.main.views.email_branding import get_preview_template from app.s3_client.s3_logo_client import upload_email_logo @@ -1478,6 +1479,41 @@ def branding_request(service_id): ) +@main.route("/services//service-settings/suppression-list", methods=["GET", "POST"]) +@user_has_permissions("manage_service") +def service_suppression_list(service_id): + """ + Page to manage suppression list for a service. + Allows removing email addresses from the SES suppression list. + """ + form = SuppressionListRemovalForm() + + if form.validate_on_submit(): + email_address = form.email_address.data + + try: + service_api_client.remove_email_from_suppression_list(service_id, email_address) + + flash(_("Successfully removed {} from the suppression list.").format(email_address), "default_with_tick") + return redirect(url_for(".service_suppression_list", service_id=service_id)) + + except HTTPError as e: + if e.status_code == 404: + flash( + _( + "This service has not sent any emails to {}. " + "You can only remove email addresses that your service has sent to." + ).format(email_address), + "error", + ) + elif e.status_code == 400: + flash(_("Invalid email address. Please check and try again."), "error") + else: + flash(_("Failed to remove email from suppression list. Please try again or contact support."), "error") + + return render_template("views/service-settings/suppression-list.html", form=form) + + @main.route("/services//data-retention", methods=["GET"]) @user_is_platform_admin def data_retention(service_id): diff --git a/app/notify_client/service_api_client.py b/app/notify_client/service_api_client.py index 350005486c..d1213ada38 100644 --- a/app/notify_client/service_api_client.py +++ b/app/notify_client/service_api_client.py @@ -613,6 +613,23 @@ def update_service_data_retention(self, service_id, data_retention_id, days_of_r def get_service_data_retention(self, service_id): return self.get("/service/{}/data-retention".format(service_id)) + def remove_email_from_suppression_list(self, service_id, email_address): + """ + Remove an email address from the SES suppression list. + + Args: + service_id: UUID of the service + email_address: Email address to remove from suppression list + + Returns: + Response from the API + + Raises: + HTTPError: If the API call fails + """ + data = {"email_address": email_address} + return self.post(f"/service/{service_id}/remove-from-suppression-list", data) + def has_accepted_tos(self, service_id): return redis_client.get(self._tos_key_name(service_id)) is not None diff --git a/app/templates/views/service-settings.html b/app/templates/views/service-settings.html index 6b542fd0c2..0a77bc52a1 100644 --- a/app/templates/views/service-settings.html +++ b/app/templates/views/service-settings.html @@ -180,6 +180,21 @@

{{ _('Your service is in trial mode') }}

{{ empty_field() }} {% endcall %} + {% call settings_row(if_has_permission='email') %} + {% set txt = _('Suppression list') %} + {{ text_field(txt) }} + {% set suppression_txt = _('Remove blocked email addresses') %} + {{ text_field(suppression_txt) }} + {% set manage_txt = _('Manage') %} + {{ edit_field( + manage_txt, + url_for('.service_suppression_list', service_id=current_service.id), + permissions=['manage_service'], + for=txt + ) + }} + {% endcall %} + {% endcall %} {% set caption = _('Text messages') %} diff --git a/app/templates/views/service-settings/suppression-list.html b/app/templates/views/service-settings/suppression-list.html new file mode 100644 index 0000000000..29c3a28729 --- /dev/null +++ b/app/templates/views/service-settings/suppression-list.html @@ -0,0 +1,42 @@ +{% extends "admin_template.html" %} +{% from "components/textbox.html" import textbox %} +{% from "components/page-header.html" import page_header %} +{% from "components/page-footer.html" import page_footer %} +{% from "components/form.html" import form_wrapper %} + +{% block service_page_title %} + {{ _('Remove email from suppression list') }} +{% endblock %} + +{% block maincolumn_content %} + + {{ page_header( + _('Remove email from suppression list'), + back_link=url_for('main.service_settings', service_id=current_service.id) + ) }} + +
+

{{ _('If an email address is on the suppression list, GC Notify will not send emails to it. This can happen if:') }}

+
    +
  • {{ _('the email server was down when we tried to send') }}
  • +
  • {{ _('the email server gave an incorrect response') }}
  • +
  • {{ _('an overactive spam filter blocked the email') }}
  • +
+ +

{{ _('You can remove an email address from the suppression list if your service has previously sent to it.') }}

+ + +
+ + {% call form_wrapper() %} + {% set hint_txt = _('Enter the email address to remove from the suppression list') %} + {{ textbox(form.email_address, hint=hint_txt) }} + {{ page_footer(_('Remove from suppression list')) }} + {% endcall %} + +{% endblock %} diff --git a/tests/app/main/views/service_settings/test_suppression_list.py b/tests/app/main/views/service_settings/test_suppression_list.py new file mode 100644 index 0000000000..ce91fd4b84 --- /dev/null +++ b/tests/app/main/views/service_settings/test_suppression_list.py @@ -0,0 +1,195 @@ +from unittest.mock import ANY, Mock + +from flask import url_for +from notifications_python_client.errors import HTTPError + +from tests.conftest import SERVICE_ONE_ID + + +class TestSuppressionListPage: + def test_service_suppression_list_page_renders( + self, + client_request, + service_one, + ): + """Test that the suppression list management page renders correctly""" + page = client_request.get("main.service_suppression_list", service_id=service_one["id"]) + + assert "Remove email from suppression list" in page.text + assert "Enter the email address to remove from the suppression list" in page.text + + def test_service_suppression_list_page_requires_manage_service_permission( + self, + client_request, + service_one, + active_user_with_permissions, + mocker, + ): + """Test that the page requires manage_service permission""" + active_user_with_permissions["permissions"][SERVICE_ONE_ID] = ["view_activity"] + client_request.login(active_user_with_permissions) + + client_request.get("main.service_suppression_list", service_id=service_one["id"], _expected_status=403) + + def test_remove_email_from_suppression_list_success( + self, + client_request, + service_one, + mocker, + ): + """Test successfully removing an email from suppression list""" + mock_remove = mocker.patch( + "app.service_api_client.remove_email_from_suppression_list", return_value={"message": "Successfully removed"} + ) + + client_request.post( + "main.service_suppression_list", + service_id=service_one["id"], + _data={"email_address": "test@example.com"}, + _expected_redirect=url_for( + "main.service_suppression_list", + service_id=service_one["id"], + ), + ) + + mock_remove.assert_called_once_with( + ANY, # service_id is a UUID object from route + "test@example.com", + ) + + def test_remove_email_from_suppression_list_shows_success_flash( + self, + client_request, + service_one, + mocker, + ): + """Test that success flash message is shown after removal""" + mocker.patch( + "app.service_api_client.remove_email_from_suppression_list", return_value={"message": "Successfully removed"} + ) + + page = client_request.post( + "main.service_suppression_list", + service_id=service_one["id"], + _data={"email_address": "test@example.com"}, + _follow_redirects=True, + ) + + assert "Successfully removed test@example.com from the suppression list" in page.text + + def test_remove_email_from_suppression_list_not_sent_by_service( + self, + client_request, + service_one, + mocker, + ): + """Test error when service hasn't sent to the email""" + mock_response = Mock() + mock_response.status_code = 404 + mock_response.json.return_value = {"message": "Service has not sent to this email"} + + mocker.patch("app.service_api_client.remove_email_from_suppression_list", side_effect=HTTPError(response=mock_response)) + + page = client_request.post( + "main.service_suppression_list", + service_id=service_one["id"], + _data={"email_address": "never-sent@example.com"}, + _expected_status=200, + ) + + assert "has not sent any emails" in page.text + + def test_remove_email_from_suppression_list_invalid_email_from_api( + self, + client_request, + service_one, + mocker, + ): + """Test error when API returns 400 for invalid email""" + mock_response = Mock() + mock_response.status_code = 400 + mock_response.json.return_value = {"message": "Invalid email address"} + + mocker.patch("app.service_api_client.remove_email_from_suppression_list", side_effect=HTTPError(response=mock_response)) + + page = client_request.post( + "main.service_suppression_list", + service_id=service_one["id"], + _data={"email_address": "bad-email@example.com"}, + _expected_status=200, + ) + + assert "Invalid email address" in page.text + + def test_remove_email_from_suppression_list_server_error( + self, + client_request, + service_one, + mocker, + ): + """Test error when API returns 500""" + mock_response = Mock() + mock_response.status_code = 500 + mock_response.json.return_value = {"message": "Internal server error"} + + mocker.patch("app.service_api_client.remove_email_from_suppression_list", side_effect=HTTPError(response=mock_response)) + + page = client_request.post( + "main.service_suppression_list", + service_id=service_one["id"], + _data={"email_address": "test@example.com"}, + _expected_status=200, + ) + + assert "Failed to remove email from suppression list" in page.text + + def test_remove_email_from_suppression_list_invalid_email_validation( + self, + client_request, + service_one, + ): + """Test validation error for invalid email format""" + page = client_request.post( + "main.service_suppression_list", + service_id=service_one["id"], + _data={"email_address": "not-an-email"}, + _expected_status=200, + ) + + # Form validation should catch invalid email + assert "error" in page.text.lower() or "valid" in page.text.lower() + + def test_remove_email_from_suppression_list_empty_email( + self, + client_request, + service_one, + ): + """Test validation error for empty email""" + page = client_request.post( + "main.service_suppression_list", service_id=service_one["id"], _data={"email_address": ""}, _expected_status=200 + ) + + # Form should show on page (not redirected) due to validation error + assert "Remove email from suppression list" in page.text + + +class TestSuppressionListSettingsLink: + def test_suppression_list_link_visible_in_service_settings( + self, + client_request, + service_one, + mocker, + mock_get_free_sms_fragment_limit, + mock_get_service_data_retention, + no_reply_to_email_addresses, + no_letter_contact_blocks, + single_sms_sender, + mock_get_service_organisation, + ): + """Test that suppression list link appears in service settings for email services""" + service_one["permissions"] = ["email"] + + page = client_request.get("main.service_settings", service_id=service_one["id"]) + + assert "Suppression list" in page.text + assert url_for("main.service_suppression_list", service_id=service_one["id"]) in str(page) From ab40124fd49b484d8a45bd155edc76af12ca2193 Mon Sep 17 00:00:00 2001 From: Jumana Bahrainwala Date: Thu, 15 Jan 2026 20:54:28 +0000 Subject: [PATCH 2/5] fix --- app/templates/views/service-settings.html | 30 ++++++++++++----------- app/translations/csv/fr.csv | 14 +++++++++++ 2 files changed, 30 insertions(+), 14 deletions(-) diff --git a/app/templates/views/service-settings.html b/app/templates/views/service-settings.html index 0a77bc52a1..4650742f72 100644 --- a/app/templates/views/service-settings.html +++ b/app/templates/views/service-settings.html @@ -84,6 +84,21 @@

{{ _('Your service is in trial mode') }}

{{ empty_field() }} {% endcall %} + {% call row() %} + {% set txt = _('Suppression list') %} + {{ text_field(txt) }} + {% set suppression_txt = _('Remove blocked email addresses') %} + {{ text_field(suppression_txt) }} + {% set manage_txt = _('Manage') %} + {{ edit_field( + manage_txt, + url_for('.service_suppression_list', service_id=current_service.id), + permissions=['manage_service'], + for=txt + ) + }} + {% endcall %} + {% endcall %} @@ -180,20 +195,7 @@

{{ _('Your service is in trial mode') }}

{{ empty_field() }} {% endcall %} - {% call settings_row(if_has_permission='email') %} - {% set txt = _('Suppression list') %} - {{ text_field(txt) }} - {% set suppression_txt = _('Remove blocked email addresses') %} - {{ text_field(suppression_txt) }} - {% set manage_txt = _('Manage') %} - {{ edit_field( - manage_txt, - url_for('.service_suppression_list', service_id=current_service.id), - permissions=['manage_service'], - for=txt - ) - }} - {% endcall %} + {% endcall %} diff --git a/app/translations/csv/fr.csv b/app/translations/csv/fr.csv index 84f765eb4d..3f07299db8 100644 --- a/app/translations/csv/fr.csv +++ b/app/translations/csv/fr.csv @@ -2498,3 +2498,17 @@ "Attachment","Pièce jointe" "files attached","fichiers joints" "Files attached to this template","Fichiers joints à ce gabarit" +"an overactive spam filter blocked the email","un filtre anti-pourriel trop actif a bloqué le courriel" +"You can remove an email address from the suppression list if your service has previously sent to it.","Vous pouvez retirer une adresse courriel de la liste de suppression si votre service lui a déjà envoyé des courriels." +"Only remove email addresses that you know are valid.","Retirez seulement les adresses courriel dont vous savez qu'elles sont valides." +"Repeatedly sending to invalid addresses can affect your service's sending reputation.","Envoyer répétitivement à des adresses invalides peut affecter la réputation d'envoi de votre service." +"Enter the email address to remove from the suppression list","Entrez l'adresse courriel à retirer de la liste de suppression" +"Remove from suppression list","Retirer de la liste de suppression" +"Suppression list","Liste de suppression" +"Remove blocked email addresses","Retirer les adresses courriel bloquées" +"Manage suppression list","Gérer la liste de suppression" +"Successfully removed {} from the suppression list.","L'adresse {} a été retirée de la liste de suppression avec succès." +"This service has not sent any emails to {}. You can only remove email addresses that your service has sent to.","Ce service n'a envoyé aucun courriel à {}. Vous ne pouvez retirer que les adresses courriel auxquelles votre service a envoyé des courriels." +"Invalid email address. Please check and try again.","Adresse courriel invalide. Veuillez vérifier et réessayer." +"Failed to remove email from suppression list. Please try again or contact support.","Échec du retrait de l'adresse courriel de la liste de suppression. Veuillez réessayer ou contacter le support." + From ce802648464f8c4945c28e2f643bc5b1b213ac37 Mon Sep 17 00:00:00 2001 From: Jumana Bahrainwala Date: Thu, 15 Jan 2026 21:24:08 +0000 Subject: [PATCH 3/5] fix --- tests/app/main/views/test_service_settings.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/app/main/views/test_service_settings.py b/tests/app/main/views/test_service_settings.py index abf4fd80fd..412cfa1c00 100644 --- a/tests/app/main/views/test_service_settings.py +++ b/tests/app/main/views/test_service_settings.py @@ -64,6 +64,7 @@ def mock_get_service_settings_page_common( "Service name Test Service Change", "Sending email address name test.service@{sending_domain} Change", "API rate limit per minute 100 calls No value", + "Suppression list Remove blocked email addresses Manage", "Label Value Action", "Send emails On Change", "Reply-to addresses Not set Manage", @@ -87,6 +88,7 @@ def mock_get_service_settings_page_common( "Service name Test Service Change", "Sending email address name test.service@{sending_domain} Change", "API rate limit per minute 100 calls No value", + "Suppression list Remove blocked email addresses Manage", "Label Value Action", "Send emails On Change", "Reply-to addresses Not set Manage", @@ -222,6 +224,7 @@ def test_organisation_name_links_to_org_dashboard( "Service name service one Change", "Sending email address name test.service@{sending_domain} Change", "API rate limit per minute 100 calls No value", + "Suppression list Remove blocked email addresses Manage", "Label Value Action", "Send emails On Change", "Reply-to addresses test@example.com Manage", @@ -243,6 +246,7 @@ def test_organisation_name_links_to_org_dashboard( "Service name service one Change", "Sending email address name test.service@{sending_domain} Change", "API rate limit per minute 100 calls No value", + "Suppression list Remove blocked email addresses Manage", "Label Value Action", "Send emails On Change", "Reply-to addresses test@example.com Manage", @@ -1444,7 +1448,7 @@ def test_and_more_hint_appears_on_settings_with_more_than_just_a_single_sender( def get_row(page, index): return normalize_spaces(page.select("tbody tr")[index].text) - assert get_row(page, 4) == "Reply-to addresses test@example.com …and 2 more Manage Reply-to addresses" + assert get_row(page, 5) == "Reply-to addresses test@example.com …and 2 more Manage Reply-to addresses" @pytest.mark.parametrize( From f7ce7f663233e6d27e48f78444425300c7fe3084 Mon Sep 17 00:00:00 2001 From: Jumana Bahrainwala Date: Thu, 15 Jan 2026 21:28:35 +0000 Subject: [PATCH 4/5] fix --- app/translations/csv/fr.csv | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/app/translations/csv/fr.csv b/app/translations/csv/fr.csv index 3f07299db8..0b84645322 100644 --- a/app/translations/csv/fr.csv +++ b/app/translations/csv/fr.csv @@ -2405,6 +2405,7 @@ "We’ve sent you the most recent newsletter","L’infolettre vous a été envoyée" "Resubscribe","Se réabonner" "Report accessibility issues","Signaler un problème d’accessibilité" +<<<<<<< HEAD "Share accessibility feedback","Donner votre avis sur l’accessibilité" "Sign up to our newsletter","S’abonner à l’infolettre de Notification GC" "billable units","FR billable units" @@ -2497,7 +2498,9 @@ "To edit attachments, save changes to your template or press back.","Pour modifier les pièces jointes, enregistrez les modifications apportées à votre modèle ou appuyez sur Retour." "Attachment","Pièce jointe" "files attached","fichiers joints" -"Files attached to this template","Fichiers joints à ce gabarit" +"Files attached to this template","Fichiers joints à ce gabarit""If an email address is on the suppression list, GC Notify will not send emails to it. This can happen if:","Si une adresse courriel est sur la liste de suppression, Notification GC n'enverra pas de courriels à cette adresse. Cela peut arriver si :" +"the email server was down when we tried to send","le serveur de courriel était en panne quand nous avons essayé d'envoyer" +"the email server gave an incorrect response","le serveur de courriel a donné une réponse incorrecte" "an overactive spam filter blocked the email","un filtre anti-pourriel trop actif a bloqué le courriel" "You can remove an email address from the suppression list if your service has previously sent to it.","Vous pouvez retirer une adresse courriel de la liste de suppression si votre service lui a déjà envoyé des courriels." "Only remove email addresses that you know are valid.","Retirez seulement les adresses courriel dont vous savez qu'elles sont valides." From 094023a538b0932d1e2bc79391fde45e7233356e Mon Sep 17 00:00:00 2001 From: Jumana Bahrainwala Date: Mon, 27 Jul 2026 15:14:25 +0000 Subject: [PATCH 5/5] update --- app/translations/csv/fr.csv | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/translations/csv/fr.csv b/app/translations/csv/fr.csv index 0b84645322..562f9ea5d2 100644 --- a/app/translations/csv/fr.csv +++ b/app/translations/csv/fr.csv @@ -2498,7 +2498,8 @@ "To edit attachments, save changes to your template or press back.","Pour modifier les pièces jointes, enregistrez les modifications apportées à votre modèle ou appuyez sur Retour." "Attachment","Pièce jointe" "files attached","fichiers joints" -"Files attached to this template","Fichiers joints à ce gabarit""If an email address is on the suppression list, GC Notify will not send emails to it. This can happen if:","Si une adresse courriel est sur la liste de suppression, Notification GC n'enverra pas de courriels à cette adresse. Cela peut arriver si :" +"Files attached to this template","Fichiers joints à ce gabarit" +"If an email address is on the suppression list, GC Notify will not send emails to it. This can happen if:","Si une adresse courriel est sur la liste de suppression, Notification GC n'enverra pas de courriels à cette adresse. Cela peut arriver si :" "the email server was down when we tried to send","le serveur de courriel était en panne quand nous avons essayé d'envoyer" "the email server gave an incorrect response","le serveur de courriel a donné une réponse incorrecte" "an overactive spam filter blocked the email","un filtre anti-pourriel trop actif a bloqué le courriel" @@ -2514,4 +2515,3 @@ "This service has not sent any emails to {}. You can only remove email addresses that your service has sent to.","Ce service n'a envoyé aucun courriel à {}. Vous ne pouvez retirer que les adresses courriel auxquelles votre service a envoyé des courriels." "Invalid email address. Please check and try again.","Adresse courriel invalide. Veuillez vérifier et réessayer." "Failed to remove email from suppression list. Please try again or contact support.","Échec du retrait de l'adresse courriel de la liste de suppression. Veuillez réessayer ou contacter le support." -