✨(aliases) create aliases
allow domain managers to create aliases on their domain
This commit is contained in:
committed by
Marie
parent
64068efff4
commit
c237bb4b10
@@ -309,3 +309,44 @@ class MailDomainInvitationSerializer(serializers.ModelSerializer):
|
|||||||
attrs["domain"] = domain
|
attrs["domain"] = domain
|
||||||
attrs["issuer"] = user
|
attrs["issuer"] = user
|
||||||
return attrs
|
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
|
||||||
|
|||||||
@@ -262,7 +262,7 @@ class MailBoxViewSet(
|
|||||||
Send a request to partially update mailbox. Cannot modify domain or local_part.
|
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
|
serializer_class = serializers.MailboxSerializer
|
||||||
filter_backends = [filters.OrderingFilter]
|
filter_backends = [filters.OrderingFilter]
|
||||||
ordering = ["-created_at"]
|
ordering = ["-created_at"]
|
||||||
@@ -279,7 +279,7 @@ class MailBoxViewSet(
|
|||||||
"""Add a specific permission for domain viewers to update their own mailbox."""
|
"""Add a specific permission for domain viewers to update their own mailbox."""
|
||||||
if self.action in ["update", "partial_update"]:
|
if self.action in ["update", "partial_update"]:
|
||||||
permission_classes = [
|
permission_classes = [
|
||||||
permissions.MailBoxPermission | permissions.IsMailboxOwnerPermission
|
permissions.DomainPermission | permissions.IsMailboxOwnerPermission
|
||||||
]
|
]
|
||||||
else:
|
else:
|
||||||
return super().get_permissions()
|
return super().get_permissions()
|
||||||
@@ -392,3 +392,62 @@ class MailDomainInvitationViewset(
|
|||||||
)
|
)
|
||||||
|
|
||||||
return queryset
|
return queryset
|
||||||
|
|
||||||
|
|
||||||
|
class AliasViewSet(
|
||||||
|
mixins.CreateModelMixin,
|
||||||
|
viewsets.GenericViewSet,
|
||||||
|
):
|
||||||
|
"""API ViewSet for aliases.
|
||||||
|
|
||||||
|
POST /api/<version>/mail-domains/<domain_slug>/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)
|
||||||
|
|||||||
@@ -16,8 +16,8 @@ class AccessPermission(core_permissions.IsAuthenticated):
|
|||||||
return abilities.get(request.method.lower(), False)
|
return abilities.get(request.method.lower(), False)
|
||||||
|
|
||||||
|
|
||||||
class MailBoxPermission(AccessPermission):
|
class DomainPermission(AccessPermission):
|
||||||
"""Permission class to manage mailboxes for a mail domain"""
|
"""Permission class to manage mailboxes and aliases for a mail domain"""
|
||||||
|
|
||||||
def has_permission(self, request, view):
|
def has_permission(self, request, view):
|
||||||
"""Check permission based on domain."""
|
"""Check permission based on domain."""
|
||||||
|
|||||||
@@ -98,3 +98,14 @@ class MailDomainInvitationFactory(factory.django.DjangoModelFactory):
|
|||||||
[role[0] for role in enums.MailDomainRoleChoices.choices]
|
[role[0] for role in enums.MailDomainRoleChoices.choices]
|
||||||
)
|
)
|
||||||
issuer = factory.SubFactory(core_factories.UserFactory)
|
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")
|
||||||
|
|||||||
33
src/backend/mailbox_manager/migrations/0026_alias.py
Normal file
33
src/backend/mailbox_manager/migrations/0026_alias.py
Normal file
@@ -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')},
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -452,3 +452,27 @@ class MailDomainInvitation(BaseInvitation):
|
|||||||
"patch": False,
|
"patch": False,
|
||||||
"put": 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}"
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -29,7 +29,11 @@ maildomain_related_router.register(
|
|||||||
viewsets.MailDomainInvitationViewset,
|
viewsets.MailDomainInvitationViewset,
|
||||||
basename="invitations",
|
basename="invitations",
|
||||||
)
|
)
|
||||||
|
maildomain_related_router.register(
|
||||||
|
"aliases",
|
||||||
|
viewsets.AliasViewSet,
|
||||||
|
basename="aliases",
|
||||||
|
)
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path(
|
path(
|
||||||
|
|||||||
@@ -668,3 +668,47 @@ class DimailAPIClient:
|
|||||||
)
|
)
|
||||||
return response
|
return response
|
||||||
return self.raise_exception_for_unexpected_response(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)
|
||||||
|
|||||||
Reference in New Issue
Block a user