From c237bb4b10f7fcd5db649099fe65980b96d3194c Mon Sep 17 00:00:00 2001 From: Marie PUPO JEAMMET Date: Wed, 9 Jul 2025 18:06:51 +0200 Subject: [PATCH] =?UTF-8?q?=E2=9C=A8(aliases)=20create=20aliases?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit allow domain managers to create aliases on their domain --- .../mailbox_manager/api/client/serializers.py | 41 +++++ .../mailbox_manager/api/client/viewsets.py | 63 +++++++- .../mailbox_manager/api/permissions.py | 4 +- src/backend/mailbox_manager/factories.py | 11 ++ .../mailbox_manager/migrations/0026_alias.py | 33 ++++ src/backend/mailbox_manager/models.py | 24 +++ .../api/aliases/test_api_aliases_create.py | 141 ++++++++++++++++++ src/backend/mailbox_manager/urls.py | 6 +- src/backend/mailbox_manager/utils/dimail.py | 44 ++++++ 9 files changed, 362 insertions(+), 5 deletions(-) create mode 100644 src/backend/mailbox_manager/migrations/0026_alias.py create mode 100644 src/backend/mailbox_manager/tests/api/aliases/test_api_aliases_create.py diff --git a/src/backend/mailbox_manager/api/client/serializers.py b/src/backend/mailbox_manager/api/client/serializers.py index c6449b0..ebf9fb9 100644 --- a/src/backend/mailbox_manager/api/client/serializers.py +++ b/src/backend/mailbox_manager/api/client/serializers.py @@ -309,3 +309,44 @@ class MailDomainInvitationSerializer(serializers.ModelSerializer): attrs["domain"] = domain attrs["issuer"] = user return attrs + + +class AliasSerializer(serializers.ModelSerializer): + """Serialize mailbox.""" + + class Meta: + model = models.Alias + fields = [ + "id", + "local_part", + "destination", + ] + read_only_fields = ["id"] + + def create(self, validated_data): + """ + Override create function to fire a request to dimail on alias creation. + """ + alias = super().create(validated_data) + + if alias.domain.status == enums.MailDomainStatusChoices.ENABLED: + client = DimailAPIClient() + # send new alias request to dimail + try: + client.create_alias(alias, self.context["request"].user.sub) + except django_exceptions.ValidationError as exc: + alias.delete() + raise exc + + return alias + + def validate_local_part(self, value): + """Validate this local part does not match a mailbox.""" + if models.Mailbox.objects.filter( + local_part=value, domain__slug=self.context["domain_slug"] + ).exists(): + raise exceptions.ValidationError( + f'Local part "{value}" already used for a mailbox.' + ) + + return value diff --git a/src/backend/mailbox_manager/api/client/viewsets.py b/src/backend/mailbox_manager/api/client/viewsets.py index 3072d65..c7d81be 100644 --- a/src/backend/mailbox_manager/api/client/viewsets.py +++ b/src/backend/mailbox_manager/api/client/viewsets.py @@ -262,7 +262,7 @@ class MailBoxViewSet( Send a request to partially update mailbox. Cannot modify domain or local_part. """ - permission_classes = [permissions.MailBoxPermission] + permission_classes = [permissions.DomainPermission] serializer_class = serializers.MailboxSerializer filter_backends = [filters.OrderingFilter] ordering = ["-created_at"] @@ -279,7 +279,7 @@ class MailBoxViewSet( """Add a specific permission for domain viewers to update their own mailbox.""" if self.action in ["update", "partial_update"]: permission_classes = [ - permissions.MailBoxPermission | permissions.IsMailboxOwnerPermission + permissions.DomainPermission | permissions.IsMailboxOwnerPermission ] else: return super().get_permissions() @@ -392,3 +392,62 @@ class MailDomainInvitationViewset( ) return queryset + + +class AliasViewSet( + mixins.CreateModelMixin, + viewsets.GenericViewSet, +): + """API ViewSet for aliases. + + POST /api//mail-domains//aliases/ with expected data: + - local_part: str + - destination: str + Return a newly created alias + """ + + lookup_field = "id" + permission_classes = [permissions.DomainPermission] + serializer_class = serializers.AliasSerializer + queryset = ( + models.Alias.objects.all().select_related("domain").order_by("-created_at") + ) + + def get_serializer_context(self): + """Extra context provided to the serializer class.""" + context = super().get_serializer_context() + context["domain_slug"] = self.kwargs["domain_slug"] + return context + + def get_queryset(self): + """Return the queryset according to the action.""" + queryset = super().get_queryset() + queryset = queryset.filter(domain__slug=self.kwargs["domain_slug"]) + + if self.action == "list": + # Determine which role the logged-in user has in the domain + user_role_query = models.MailDomainAccess.objects.filter( + user=self.request.user, domain__slug=self.kwargs["domain_slug"] + ).values("role") + + queryset = ( + # The logged-in user should be part of a domain to see its accesses + queryset.filter( + domain__accesses__user=self.request.user, + ) + # Abilities are computed based on logged-in user's role and + # the user role on each domain access + .annotate(user_role=Subquery(user_role_query)) + .distinct() + ) + + return queryset + + def perform_create(self, serializer): + """Create new mailbox.""" + domain_slug = self.kwargs.get("domain_slug", "") + if domain_slug: + serializer.validated_data["domain"] = models.MailDomain.objects.get( + slug=domain_slug + ) + super().perform_create(serializer) diff --git a/src/backend/mailbox_manager/api/permissions.py b/src/backend/mailbox_manager/api/permissions.py index cdab4a7..348542d 100644 --- a/src/backend/mailbox_manager/api/permissions.py +++ b/src/backend/mailbox_manager/api/permissions.py @@ -16,8 +16,8 @@ class AccessPermission(core_permissions.IsAuthenticated): return abilities.get(request.method.lower(), False) -class MailBoxPermission(AccessPermission): - """Permission class to manage mailboxes for a mail domain""" +class DomainPermission(AccessPermission): + """Permission class to manage mailboxes and aliases for a mail domain""" def has_permission(self, request, view): """Check permission based on domain.""" diff --git a/src/backend/mailbox_manager/factories.py b/src/backend/mailbox_manager/factories.py index 497c9b5..cb553ec 100644 --- a/src/backend/mailbox_manager/factories.py +++ b/src/backend/mailbox_manager/factories.py @@ -98,3 +98,14 @@ class MailDomainInvitationFactory(factory.django.DjangoModelFactory): [role[0] for role in enums.MailDomainRoleChoices.choices] ) issuer = factory.SubFactory(core_factories.UserFactory) + + +class AliasFactory(factory.django.DjangoModelFactory): + """A factory to create aliases.""" + + class Meta: + model = models.Alias + + domain = factory.SubFactory(MailDomainEnabledFactory) + local_part = factory.Faker("word") + destination = factory.Faker("email") diff --git a/src/backend/mailbox_manager/migrations/0026_alias.py b/src/backend/mailbox_manager/migrations/0026_alias.py new file mode 100644 index 0000000..e506bd0 --- /dev/null +++ b/src/backend/mailbox_manager/migrations/0026_alias.py @@ -0,0 +1,33 @@ +# Generated by Django 5.2.6 on 2025-10-13 12:55 + +import django.db.models.deletion +import uuid +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('mailbox_manager', '0025_alter_mailbox_secondary_email'), + ] + + operations = [ + migrations.CreateModel( + name='Alias', + fields=[ + ('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')), + ('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')), + ('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')), + ('local_part', models.CharField(max_length=100)), + ('destination', models.EmailField(max_length=254, verbose_name='destination address')), + ('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='aliases', to='mailbox_manager.maildomain')), + ], + options={ + 'verbose_name': 'Alias', + 'verbose_name_plural': 'Aliases', + 'db_table': 'people_aliases', + 'ordering': ['-created_at'], + 'unique_together': {('local_part', 'destination')}, + }, + ), + ] diff --git a/src/backend/mailbox_manager/models.py b/src/backend/mailbox_manager/models.py index 86710f4..ab8375c 100644 --- a/src/backend/mailbox_manager/models.py +++ b/src/backend/mailbox_manager/models.py @@ -452,3 +452,27 @@ class MailDomainInvitation(BaseInvitation): "patch": False, "put": False, } + + +class Alias(BaseModel): + """Model for aliases.""" + + local_part = models.CharField(max_length=100, blank=False) + destination = models.EmailField(_("destination address"), null=False, blank=False) + domain = models.ForeignKey( + MailDomain, + on_delete=models.CASCADE, + related_name="aliases", + null=False, + blank=False, + ) + + class Meta: + db_table = "people_aliases" + verbose_name = _("Alias") + verbose_name_plural = _("Aliases") + unique_together = ("local_part", "destination") + ordering = ["-created_at"] + + def __str__(self): + return f"{self.local_part} to {self.destination}" diff --git a/src/backend/mailbox_manager/tests/api/aliases/test_api_aliases_create.py b/src/backend/mailbox_manager/tests/api/aliases/test_api_aliases_create.py new file mode 100644 index 0000000..69e56a4 --- /dev/null +++ b/src/backend/mailbox_manager/tests/api/aliases/test_api_aliases_create.py @@ -0,0 +1,141 @@ +""" +Tests for mailbox Aliases API endpoint in People's app mailbox_manager. +Focus on "create" action. +""" + +import json +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 +from mailbox_manager.tests.fixtures.dimail import TOKEN_OK + +pytestmark = pytest.mark.django_db + + +def test_api_aliases_create__anonymous(): + """Anonymous user should not create aliases""" + domain = factories.MailDomainEnabledFactory() + + response = APIClient().post( + f"/api/v1.0/mail-domains/{domain.slug}/aliases/", + {"whatever": "this should not be updated"}, + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + assert not models.Alias.objects.exists() + + +def test_api_aliases_create__no_access_forbidden(): + """User authenticated but not having domain permission should not create aliases.""" + domain = factories.MailDomainEnabledFactory() + + client = APIClient() + client.force_login(core_factories.UserFactory()) + response = client.post( + f"/api/v1.0/mail-domains/{domain.slug}/aliases/", + {"local_part": "intrusive", "destination": "intrusive@mail.com"}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + assert not models.Alias.objects.exists() + + +def test_api_aliases_create__viewer_forbidden(): + """Domain viewers should not create aliases.""" + domain = factories.MailDomainEnabledFactory() + access = factories.MailDomainAccessFactory(role="viewer", domain=domain) + + client = APIClient() + client.force_login(access.user) + response = client.post( + f"/api/v1.0/mail-domains/{domain.slug}/aliases/", + {"local_part": "intrusive", "destination": "intrusive@mail.com"}, + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + assert not models.Alias.objects.exists() + + +def test_api_aliases_create__duplicate_forbidden(): + """Cannot create alias if same local part + destination.""" + access = factories.MailDomainAccessFactory( + role="owner", domain=factories.MailDomainEnabledFactory() + ) + + existing_alias = factories.AliasFactory(domain=access.domain) + client = APIClient() + client.force_login(access.user) + response = client.post( + f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/", + { + "local_part": existing_alias.local_part, + "destination": existing_alias.destination, + }, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert models.Alias.objects.filter(domain=access.domain).count() == 1 + + +def test_api_aliases_create__existing_mailbox_bad_request(): + """Cannot create alias if local_part is already used by a mailbox.""" + access = factories.MailDomainAccessFactory( + role="owner", domain=factories.MailDomainEnabledFactory() + ) + mailbox = factories.MailboxFactory(domain=access.domain) + + client = APIClient() + client.force_login(access.user) + response = client.post( + f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/", + {"local_part": mailbox.local_part, "destination": "someone@outsidedomain.com"}, + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert not models.Alias.objects.exists() + + +@responses.activate +@pytest.mark.parametrize( + "role", + [enums.MailDomainRoleChoices.OWNER, enums.MailDomainRoleChoices.ADMIN], +) +def test_api_aliases_create__admins_ok(role): + """Domain admins should be able to create aliases.""" + access = factories.MailDomainAccessFactory(role=role) + + client = APIClient() + client.force_login(access.user) + # Prepare responses + responses.add( + responses.GET, + re.compile(r".*/token/"), + body=TOKEN_OK, + status=status.HTTP_200_OK, + content_type="application/json", + ) + responses.add( + responses.POST, + re.compile(rf".*/domains/{access.domain.name}/aliases/"), + body=json.dumps( + { + "username": "contact", + "domain": access.domain.name, + "destination": "someone@outsidedomain.com", + "allow_to_send": True, + } + ), + status=status.HTTP_201_CREATED, + content_type="application/json", + ) + + response = client.post( + f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/", + {"local_part": "contact", "destination": "someone@outsidedomain.com"}, + ) + assert response.status_code == status.HTTP_201_CREATED + alias = models.Alias.objects.get() + assert alias.local_part == "contact" + assert alias.destination == "someone@outsidedomain.com" diff --git a/src/backend/mailbox_manager/urls.py b/src/backend/mailbox_manager/urls.py index 42f4991..4f8cc7a 100644 --- a/src/backend/mailbox_manager/urls.py +++ b/src/backend/mailbox_manager/urls.py @@ -29,7 +29,11 @@ maildomain_related_router.register( viewsets.MailDomainInvitationViewset, basename="invitations", ) - +maildomain_related_router.register( + "aliases", + viewsets.AliasViewSet, + basename="aliases", +) urlpatterns = [ path( diff --git a/src/backend/mailbox_manager/utils/dimail.py b/src/backend/mailbox_manager/utils/dimail.py index 48105b5..bcb5750 100644 --- a/src/backend/mailbox_manager/utils/dimail.py +++ b/src/backend/mailbox_manager/utils/dimail.py @@ -668,3 +668,47 @@ class DimailAPIClient: ) return response return self.raise_exception_for_unexpected_response(response) + + def create_alias(self, alias, request_user=None): + """Send a Create alias request to mail provisioning API.""" + + payload = { + "user_name": alias.local_part, + "destination": alias.destination, + } + headers = self.get_headers() + + try: + response = session.post( + f"{self.API_URL}/domains/{alias.domain.name}/aliases/", + json=payload, + headers=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 + + if response.status_code == status.HTTP_201_CREATED: + logger.info( + "User %s linked alias %s to a new email.", + request_user, + f"{alias.local_part}@{alias.domain}", + ) + return response + + if response.status_code == status.HTTP_403_FORBIDDEN: + logger.error( + "[DIMAIL] 403 Forbidden: you cannot access domain %s", + str(alias.domain), + ) + raise exceptions.PermissionDenied( + "Permission denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS." + ) + + return self.raise_exception_for_unexpected_response(response)