Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions app/main/forms.py
Original file line number Diff line number Diff line change
Expand Up @@ -1610,6 +1610,13 @@ class ServiceDataRetentionEditForm(StripWhitespaceForm):
)


class SuppressionListRemovalForm(StripWhitespaceForm):
email_address = email_address(
_l("Email address"),
gov_user=False,
)


class ReturnedLettersForm(StripWhitespaceForm):
references = TextAreaField(
"Letter references",
Expand Down
36 changes: 36 additions & 0 deletions app/main/views/service_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1473,6 +1474,41 @@ def branding_request(service_id):
)


@main.route("/services/<uuid:service_id>/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/<service_id>/data-retention", methods=["GET"])
@user_is_platform_admin
def data_retention(service_id):
Expand Down
17 changes: 17 additions & 0 deletions app/notify_client/service_api_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -594,6 +594,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

Expand Down
17 changes: 17 additions & 0 deletions app/templates/views/service-settings.html
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,21 @@ <h2 class="heading-small p-0 m-0">{{ _('Your service is in trial mode') }}</h2>
{{ 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 %}


Expand Down Expand Up @@ -178,6 +193,8 @@ <h2 class="heading-small p-0 m-0">{{ _('Your service is in trial mode') }}</h2>
{{ empty_field() }}
{% endcall %}



{% endcall %}

{% set caption = _('Text messages') %}
Expand Down
42 changes: 42 additions & 0 deletions app/templates/views/service-settings/suppression-list.html
Original file line number Diff line number Diff line change
@@ -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)
) }}

<div class="form-group">
<p>{{ _('If an email address is on the suppression list, GC Notify will not send emails to it. This can happen if:') }}</p>
<ul class="list list-bullet">
<li>{{ _('the email server was down when we tried to send') }}</li>
<li>{{ _('the email server gave an incorrect response') }}</li>
<li>{{ _('an overactive spam filter blocked the email') }}</li>
</ul>

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

<div class="banner banner-warning mt-8 mb-8">
<p class="mb-0">
<strong>{{ _('Only remove email addresses that you know are valid.') }}</strong>
{{ _('Repeatedly sending to invalid addresses can affect your service\'s sending reputation.') }}
</p>
</div>
</div>

{% 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 %}
18 changes: 17 additions & 1 deletion app/translations/csv/fr.csv
Original file line number Diff line number Diff line change
Expand Up @@ -2385,4 +2385,20 @@
"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é"
"Share accessibility feedback","Donner votre avis sur l’accessibilité"
"Share accessibility feedback","Donner votre avis sur l’accessibilité""Remove email from suppression list","Retirer l'adresse courriel de la liste de suppression"
"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&nbsp;GC n'enverra pas de courriels à cette adresse. Cela peut arriver&nbsp;si&nbsp;:"
"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."
"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."
195 changes: 195 additions & 0 deletions tests/app/main/views/service_settings/test_suppression_list.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading