From bc1cbef168585afc558b6e8301f8ff7c44010b95 Mon Sep 17 00:00:00 2001 From: Marie PUPO JEAMMET Date: Fri, 9 Jan 2026 15:55:08 +0100 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(aliases)=20delete=20all=20aliases=20o?= =?UTF-8?q?f=20a=20given=20local=20part?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit added a bulk delete method for aliases, when filtering on local part this is convenient when in need to delete the local part and all its destinations in a single call --- CHANGELOG.md | 1 + .../mailbox_manager/api/client/viewsets.py | 26 ++++ .../aliases/test_api_aliases_bulk_delete.py | 129 ++++++++++++++++++ src/backend/mailbox_manager/utils/dimail.py | 23 +++- 4 files changed, 178 insertions(+), 1 deletion(-) create mode 100644 src/backend/mailbox_manager/tests/api/aliases/test_api_aliases_bulk_delete.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 438d282..9d7b0fc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to ## [Unreleased] +- ✨(aliases) delete all aliases in one call #1002 - ✨(aliases) fix deleting single aliases #1002 - 🔥(plugins) remove CommuneCreation plugin diff --git a/src/backend/mailbox_manager/api/client/viewsets.py b/src/backend/mailbox_manager/api/client/viewsets.py index 76ec520..8da3438 100644 --- a/src/backend/mailbox_manager/api/client/viewsets.py +++ b/src/backend/mailbox_manager/api/client/viewsets.py @@ -425,6 +425,9 @@ class AliasViewSet( DELETE /api//mail-domains//aliases// Delete targeted alias + + DELETE /api//mail-domains//aliases/?local_part=/ + Delete all aliases of targeted local_part """ lookup_field = "pk" @@ -477,3 +480,26 @@ class AliasViewSet( ) return Response(status=status.HTTP_204_NO_CONTENT) + + @action(methods=["DELETE"], detail=False) + def delete(self, request, *args, **kwargs): + """Bulk delete aliases. Filtering is required and accepted filter is local_part.""" + + if "local_part" not in self.request.query_params: + return Response(status=status.HTTP_400_BAD_REQUEST) + + local_part = self.request.query_params["local_part"] + queryset = self.get_queryset().filter( + local_part=local_part + ) # Manually call get_queryset to filter by domain and role + if not queryset: + raise Http404("No Alias matches the given query.") + + # view is bounded to a domain, fetch is from the queryset to spare a dedicated DB request" + domain_name = queryset[0].domain.name + queryset.delete() + + client = DimailAPIClient() + client.delete_multiple_alias(local_part, domain_name) + + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/src/backend/mailbox_manager/tests/api/aliases/test_api_aliases_bulk_delete.py b/src/backend/mailbox_manager/tests/api/aliases/test_api_aliases_bulk_delete.py new file mode 100644 index 0000000..bacbfe9 --- /dev/null +++ b/src/backend/mailbox_manager/tests/api/aliases/test_api_aliases_bulk_delete.py @@ -0,0 +1,129 @@ +""" +Tests for aliases API endpoint in People's app mailbox_manager. +Focus on "bulk delete" action. +""" +# pylint: disable=W0613 + +import re + +import pytest +import responses +from rest_framework import status +from rest_framework.test import APIClient + +from core import factories as core_factories + +from mailbox_manager import enums, factories, models + +pytestmark = pytest.mark.django_db + + +def test_api_aliases_bulk_delete__anonymous_get_401(): + """Anonymous user should not be able to bulk delete.""" + mail_domain = factories.MailDomainFactory() + alias_, _, _ = factories.AliasFactory.create_batch(3, domain=mail_domain) + + client = APIClient() + response = client.delete( + f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/?local_part={alias_.local_part}", + ) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert models.Alias.objects.count() == 3 + + +def test_api_aliases_bulk_delete__no_access_get_404(): + """User with no access to domain should not be able to bulk delete.""" + mail_domain = factories.MailDomainFactory() + alias_, _, _ = factories.AliasFactory.create_batch(3, domain=mail_domain) + + client = APIClient() + client.force_login(core_factories.UserFactory()) + response = client.delete( + f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/?local_part={alias_.local_part}", + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + assert models.Alias.objects.count() == 3 + + +def test_api_aliases_bulk_delete__viewer_get_403(): + """Viewer user should not be able to bulk delete.""" + access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.VIEWER) + alias_, _, _ = factories.AliasFactory.create_batch(3, domain=access.domain) + + client = APIClient() + client.force_login(access.user) + response = client.delete( + f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/?local_part={alias_.local_part}", + ) + + assert response.status_code == status.HTTP_403_FORBIDDEN + assert models.Alias.objects.count() == 3 + + +@responses.activate +def test_api_aliases_bulk_delete__administrators_allowed_all_destination( + dimail_token_ok, +): + """ + Administrators of a domain should be allowed to bulk delete all aliases + of a given local_part. + """ + authenticated_user = core_factories.UserFactory() + mail_domain = factories.MailDomainFactory( + users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)] + ) + alias_ = factories.AliasFactory(domain=mail_domain) + factories.AliasFactory.create_batch( + 2, domain=mail_domain, local_part=alias_.local_part + ) + + # additional aliases that shouldn't be affected + factories.AliasFactory.create_batch( + 2, domain=mail_domain, destination=alias_.destination + ) + factories.AliasFactory( + local_part=alias_.local_part, + destination=alias_.destination, + ) + + # Mock dimail response + responses.delete( + re.compile(r".*/aliases/"), + status=status.HTTP_204_NO_CONTENT, + content_type="application/json", + ) + + client = APIClient() + client.force_login(authenticated_user) + response = client.delete( + f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/?local_part={alias_.local_part}", + ) + + assert response.status_code == status.HTTP_204_NO_CONTENT + assert models.Alias.objects.count() == 3 + assert not models.Alias.objects.filter( + domain=mail_domain, local_part=alias_.local_part + ).exists() + + +def test_api_aliases_bulk_delete__no_local_part_bad_request(): + """Filtering by local part is mandatory when bulk deleting aliases.""" + authenticated_user = core_factories.UserFactory() + mail_domain = factories.MailDomainFactory( + users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)] + ) + alias_ = factories.AliasFactory(domain=mail_domain) + factories.AliasFactory.create_batch( + 2, domain=mail_domain, local_part=alias_.local_part + ) + + client = APIClient() + client.force_login(authenticated_user) + response = client.delete( + f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/", + ) + + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert models.Alias.objects.count() == 3 diff --git a/src/backend/mailbox_manager/utils/dimail.py b/src/backend/mailbox_manager/utils/dimail.py index 1f7715e..7ca9801 100644 --- a/src/backend/mailbox_manager/utils/dimail.py +++ b/src/backend/mailbox_manager/utils/dimail.py @@ -778,11 +778,32 @@ class DimailAPIClient: str(alias.domain), ) # we don't raise error because we actually want this alias to be deleted - # to match dimail's states return response return self._raise_exception_for_unexpected_response(response) + def delete_multiple_alias(self, local_part, domain_name): + """Send a Delete alias request to mail provisioning API.""" + + try: + response = session.delete( + f"{self.API_URL}/domains/{domain_name}/aliases/{local_part}/all", + json={}, + headers=self._get_headers(), + verify=True, + timeout=self.API_TIMEOUT, + ) + except requests.exceptions.ConnectionError as error: + logger.error( + "Connection error while trying to reach %s.", + self.API_URL, + exc_info=error, + ) + raise error + # response.raise_for_status() + + return response + def import_aliases(self, domain): """Import aliases from dimail. Useful if people fall out of sync with dimail."""