|
| 1 | +# notification-admin Changes Required |
| 2 | + |
| 3 | +This document outlines the changes needed in the notification-admin repository to complement the suppression list removal feature implemented in notification-api. |
| 4 | + |
| 5 | +## Overview |
| 6 | + |
| 7 | +The notification-admin needs to provide a UI under the service settings page where users can: |
| 8 | +1. Input an email address |
| 9 | +2. Check if it's on the suppression list |
| 10 | +3. Remove it from the suppression list (if the service has sent to it before) |
| 11 | + |
| 12 | +## Required Changes |
| 13 | + |
| 14 | +### 1. Add Route in `app/main/views/service_settings.py` |
| 15 | + |
| 16 | +Add a new route for the suppression list management page: |
| 17 | + |
| 18 | +```python |
| 19 | +@main.route("/services/<uuid:service_id>/service-settings/suppression-list", methods=["GET", "POST"]) |
| 20 | +@user_has_permissions("manage_service") |
| 21 | +def service_suppression_list(service_id): |
| 22 | + """ |
| 23 | + Page to manage suppression list for a service. |
| 24 | + Allows removing email addresses from the SES suppression list. |
| 25 | + """ |
| 26 | + from app.main.forms import SuppressionListRemovalForm |
| 27 | + |
| 28 | + form = SuppressionListRemovalForm() |
| 29 | + |
| 30 | + if form.validate_on_submit(): |
| 31 | + email_address = form.email_address.data |
| 32 | + |
| 33 | + try: |
| 34 | + # Call notification-api endpoint to remove from suppression list |
| 35 | + service_api_client.remove_email_from_suppression_list( |
| 36 | + service_id, |
| 37 | + email_address |
| 38 | + ) |
| 39 | + |
| 40 | + flash( |
| 41 | + f"Successfully removed {email_address} from the suppression list.", |
| 42 | + "default_with_tick" |
| 43 | + ) |
| 44 | + return redirect(url_for(".service_suppression_list", service_id=service_id)) |
| 45 | + |
| 46 | + except HTTPError as e: |
| 47 | + if e.status_code == 404: |
| 48 | + flash( |
| 49 | + f"This service has not sent any emails to {email_address}. " |
| 50 | + "You can only remove email addresses that your service has sent to.", |
| 51 | + "error" |
| 52 | + ) |
| 53 | + elif e.status_code == 400: |
| 54 | + flash( |
| 55 | + "Invalid email address. Please check and try again.", |
| 56 | + "error" |
| 57 | + ) |
| 58 | + else: |
| 59 | + flash( |
| 60 | + "Failed to remove email from suppression list. Please try again or contact support.", |
| 61 | + "error" |
| 62 | + ) |
| 63 | + |
| 64 | + return render_template( |
| 65 | + "views/service-settings/suppression-list.html", |
| 66 | + form=form |
| 67 | + ) |
| 68 | +``` |
| 69 | + |
| 70 | +### 2. Add Form in `app/main/forms.py` |
| 71 | + |
| 72 | +Create a form for email address input: |
| 73 | + |
| 74 | +```python |
| 75 | +class SuppressionListRemovalForm(StripWhitespaceForm): |
| 76 | + email_address = email_address( |
| 77 | + "Email address", |
| 78 | + validators=[ |
| 79 | + DataRequired(message="Cannot be empty"), |
| 80 | + ValidEmail() |
| 81 | + ] |
| 82 | + ) |
| 83 | +``` |
| 84 | + |
| 85 | +### 3. Add API Client Method in `app/notify_client/service_api_client.py` |
| 86 | + |
| 87 | +Add method to call the notification-api endpoint: |
| 88 | + |
| 89 | +```python |
| 90 | +def remove_email_from_suppression_list(self, service_id, email_address): |
| 91 | + """ |
| 92 | + Remove an email address from the SES suppression list. |
| 93 | + |
| 94 | + Args: |
| 95 | + service_id: UUID of the service |
| 96 | + email_address: Email address to remove from suppression list |
| 97 | + |
| 98 | + Returns: |
| 99 | + Response from the API |
| 100 | + |
| 101 | + Raises: |
| 102 | + HTTPError: If the API call fails |
| 103 | + """ |
| 104 | + data = {"email_address": email_address} |
| 105 | + return self.post( |
| 106 | + url=f"/service/{service_id}/remove-from-suppression-list", |
| 107 | + data=data |
| 108 | + ) |
| 109 | +``` |
| 110 | + |
| 111 | +### 4. Create Template `app/templates/views/service-settings/suppression-list.html` |
| 112 | + |
| 113 | +Create the HTML template: |
| 114 | + |
| 115 | +```html |
| 116 | +{% extends "withnav_template.html" %} |
| 117 | +{% from "components/form.html" import form_wrapper %} |
| 118 | +{% from "components/page-header.html" import page_header %} |
| 119 | + |
| 120 | +{% block service_page_title %} |
| 121 | + Remove email from suppression list |
| 122 | +{% endblock %} |
| 123 | + |
| 124 | +{% block maincolumn_content %} |
| 125 | + |
| 126 | + {{ page_header("Remove email from suppression list") }} |
| 127 | + |
| 128 | + <div class="govuk-body"> |
| 129 | + <p> |
| 130 | + If an email address is on the suppression list, Notify will not send emails to it. |
| 131 | + This can happen if: |
| 132 | + </p> |
| 133 | + <ul class="govuk-list govuk-list--bullet"> |
| 134 | + <li>the email server was down when we tried to send</li> |
| 135 | + <li>the email server gave an incorrect response</li> |
| 136 | + <li>an overactive spam filter blocked the email</li> |
| 137 | + </ul> |
| 138 | + |
| 139 | + <p> |
| 140 | + You can remove an email address from the suppression list if your service has |
| 141 | + previously sent to it. |
| 142 | + </p> |
| 143 | + |
| 144 | + <div class="govuk-warning-text"> |
| 145 | + <span class="govuk-warning-text__icon" aria-hidden="true">!</span> |
| 146 | + <strong class="govuk-warning-text__text"> |
| 147 | + <span class="govuk-warning-text__assistive">Warning</span> |
| 148 | + Only remove email addresses that you know are valid. Repeatedly sending to |
| 149 | + invalid addresses can affect your service's sending reputation. |
| 150 | + </strong> |
| 151 | + </div> |
| 152 | + </div> |
| 153 | + |
| 154 | + {% call form_wrapper() %} |
| 155 | + {{ form.email_address(param_extensions={"hint": {"text": "Enter the email address to remove from the suppression list"}}) }} |
| 156 | + {{ page_footer("Remove from suppression list") }} |
| 157 | + {% endcall %} |
| 158 | + |
| 159 | +{% endblock %} |
| 160 | +``` |
| 161 | + |
| 162 | +### 5. Add Link in Service Settings Page |
| 163 | + |
| 164 | +In `app/templates/views/service-settings/index.html`, add a link to the suppression list management page: |
| 165 | + |
| 166 | +```html |
| 167 | +<div class="settings-table body-copy-table"> |
| 168 | + <h2 class="heading-medium">Email settings</h2> |
| 169 | + <div class="govuk-grid-row bottom-gutter-3-2"> |
| 170 | + <!-- Existing email settings --> |
| 171 | + </div> |
| 172 | + |
| 173 | + <div class="govuk-grid-row bottom-gutter-3-2"> |
| 174 | + <div class="govuk-grid-column-two-thirds"> |
| 175 | + <h3 class="heading-small">Suppression list</h3> |
| 176 | + <p class="govuk-body"> |
| 177 | + Remove email addresses from the suppression list if they were blocked incorrectly. |
| 178 | + </p> |
| 179 | + </div> |
| 180 | + <div class="govuk-grid-column-one-third"> |
| 181 | + <a href="{{ url_for('.service_suppression_list', service_id=current_service.id) }}" class="govuk-link govuk-link--no-visited-state"> |
| 182 | + Manage suppression list |
| 183 | + </a> |
| 184 | + </div> |
| 185 | + </div> |
| 186 | +</div> |
| 187 | +``` |
| 188 | + |
| 189 | +### 6. Add Tests |
| 190 | + |
| 191 | +Create `tests/app/main/views/service_settings/test_suppression_list.py`: |
| 192 | + |
| 193 | +```python |
| 194 | +import pytest |
| 195 | +from flask import url_for |
| 196 | +from unittest.mock import Mock |
| 197 | +from requests import HTTPError |
| 198 | + |
| 199 | + |
| 200 | +def test_service_suppression_list_page_renders( |
| 201 | + client_request, |
| 202 | + service_one, |
| 203 | + mocker |
| 204 | +): |
| 205 | + """Test that the suppression list management page renders""" |
| 206 | + page = client_request.get( |
| 207 | + "main.service_suppression_list", |
| 208 | + service_id=service_one["id"] |
| 209 | + ) |
| 210 | + |
| 211 | + assert "Remove email from suppression list" in page |
| 212 | + assert "Enter the email address" in page |
| 213 | + |
| 214 | + |
| 215 | +def test_remove_email_from_suppression_list_success( |
| 216 | + client_request, |
| 217 | + service_one, |
| 218 | + mocker, |
| 219 | + mock_remove_email_from_suppression_list |
| 220 | +): |
| 221 | + """Test successfully removing an email from suppression list""" |
| 222 | + client_request.post( |
| 223 | + "main.service_suppression_list", |
| 224 | + service_id=service_one["id"], |
| 225 | + _data={"email_address": "test@example.com"}, |
| 226 | + _expected_redirect=url_for( |
| 227 | + "main.service_suppression_list", |
| 228 | + service_id=service_one["id"] |
| 229 | + ) |
| 230 | + ) |
| 231 | + |
| 232 | + mock_remove_email_from_suppression_list.assert_called_once_with( |
| 233 | + service_one["id"], |
| 234 | + "test@example.com" |
| 235 | + ) |
| 236 | + |
| 237 | + |
| 238 | +def test_remove_email_from_suppression_list_not_sent_by_service( |
| 239 | + client_request, |
| 240 | + service_one, |
| 241 | + mocker |
| 242 | +): |
| 243 | + """Test error when service hasn't sent to the email""" |
| 244 | + mock_remove = mocker.patch( |
| 245 | + "app.service_api_client.remove_email_from_suppression_list", |
| 246 | + side_effect=HTTPError(response=Mock(status_code=404)) |
| 247 | + ) |
| 248 | + |
| 249 | + page = client_request.post( |
| 250 | + "main.service_suppression_list", |
| 251 | + service_id=service_one["id"], |
| 252 | + _data={"email_address": "never-sent@example.com"}, |
| 253 | + _expected_status=200 |
| 254 | + ) |
| 255 | + |
| 256 | + assert "has not sent any emails" in page |
| 257 | + |
| 258 | + |
| 259 | +def test_remove_email_from_suppression_list_invalid_email( |
| 260 | + client_request, |
| 261 | + service_one, |
| 262 | + mocker |
| 263 | +): |
| 264 | + """Test validation error for invalid email""" |
| 265 | + page = client_request.post( |
| 266 | + "main.service_suppression_list", |
| 267 | + service_id=service_one["id"], |
| 268 | + _data={"email_address": "not-an-email"}, |
| 269 | + _expected_status=200 |
| 270 | + ) |
| 271 | + |
| 272 | + assert "Not a valid email address" in page |
| 273 | + |
| 274 | + |
| 275 | +@pytest.fixture |
| 276 | +def mock_remove_email_from_suppression_list(mocker): |
| 277 | + return mocker.patch( |
| 278 | + "app.service_api_client.remove_email_from_suppression_list" |
| 279 | + ) |
| 280 | +``` |
| 281 | + |
| 282 | +## Testing the Integration |
| 283 | + |
| 284 | +1. Start both notification-api and notification-admin locally |
| 285 | +2. Log in to notification-admin |
| 286 | +3. Navigate to a service's settings page |
| 287 | +4. Click on "Manage suppression list" |
| 288 | +5. Enter an email address that the service has sent to |
| 289 | +6. Verify the success message appears |
| 290 | +7. Check that a Freshdesk ticket was created |
| 291 | +8. Verify the email is removed from the AWS SES suppression list |
| 292 | + |
| 293 | +## Security Considerations |
| 294 | + |
| 295 | +- The endpoint is protected by the `manage_service` permission |
| 296 | +- Users can only remove emails their service has sent to |
| 297 | +- All actions are logged and create Freshdesk tickets for audit trail |
| 298 | +- Rate limiting should be considered to prevent abuse |
| 299 | + |
| 300 | +## Additional Notes |
| 301 | + |
| 302 | +- The feature only works for email addresses that the service has actually sent to (verified in notification-api) |
| 303 | +- Freshdesk tickets are created automatically to track removals |
| 304 | +- The AWS SES v2 API is used for suppression list management |
| 305 | +- Users should be warned about the risks of repeatedly sending to invalid addresses |
0 commit comments