diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a758d1..69392a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ and this project adheres to ## [Unreleased] +- ✨(invitations) refresh expired invitations + ## [1.23.0] - 2026-02-12 ### Added diff --git a/src/backend/core/models.py b/src/backend/core/models.py index 33a25af..82b365e 100644 --- a/src/backend/core/models.py +++ b/src/backend/core/models.py @@ -36,7 +36,7 @@ from core.plugins.registry import registry as plugin_hooks_registry from core.utils.webhooks import webhooks_synchronizer from core.validators import get_field_validators_from_setting -from mailbox_manager.exceptions import EmailAlreadyKnownException +from core.exceptions import EmailAlreadyKnownException logger = getLogger(__name__) @@ -1001,14 +1001,21 @@ class BaseInvitation(BaseModel): if User.objects.filter(email__iexact=self.email).exists(): raise EmailAlreadyKnownException + def refresh(self): + """A simple way to refresh invitation and move expiration date.""" + self.clean() + self.updated_at = timezone.now() + + + @property def is_expired(self): """Calculate if invitation is still valid or has expired.""" - if not self.created_at: + if not self.updated_at: return None validity_duration = timedelta(seconds=settings.INVITATION_VALIDITY_DURATION) - return timezone.now() > (self.created_at + validity_duration) + return timezone.now() > (self.updated_at + validity_duration) def _get_mail_subject(self): """Get the subject of the invitation.""" diff --git a/src/backend/mailbox_manager/api/client/viewsets.py b/src/backend/mailbox_manager/api/client/viewsets.py index f032dc5..9269e6e 100644 --- a/src/backend/mailbox_manager/api/client/viewsets.py +++ b/src/backend/mailbox_manager/api/client/viewsets.py @@ -2,6 +2,7 @@ from django.db.models import Q, Subquery from django.http import Http404 +from django.shortcuts import get_object_or_404 from rest_framework import exceptions, filters, mixins, status, viewsets from rest_framework.decorators import action @@ -428,6 +429,14 @@ class MailDomainInvitationViewset( ) raise exc + @action(detail=True, methods=["post"]) + def refresh(self, request, *args, **kwargs): # pylint: disable=unused-argument + """Enable mailbox. Send a request to dimail and change status in our DB""" + invitation = get_object_or_404(models.MailDomainInvitation, id=kwargs["id"]) + invitation.refresh() + invitation.email_invitation() + return Response(serializers.MailDomainInvitationSerializer(invitation).data) + class AliasViewSet( mixins.CreateModelMixin, diff --git a/src/backend/mailbox_manager/models.py b/src/backend/mailbox_manager/models.py index 0cbb344..10f7fba 100644 --- a/src/backend/mailbox_manager/models.py +++ b/src/backend/mailbox_manager/models.py @@ -18,6 +18,7 @@ from django.utils.translation import get_language, gettext, override from django.utils.translation import gettext_lazy as _ from core.models import BaseInvitation, BaseModel, Organization, User +from core.exceptions import EmailAlreadyKnownException from mailbox_manager.enums import ( MailboxStatusChoices, @@ -413,6 +414,23 @@ class MailDomainInvitation(BaseInvitation): ) ] + def refresh(self): + """Create domain access if refresh fail because user was created + after invitation expiration""" + try: + super().refresh() + except EmailAlreadyKnownException as exc: + user = User.objects.get(email__iexact=self.email) + + MailDomainAccess.objects.create( + user=user, + domain=self.domain, + role=self.role, + ) + + self.delete() # delete related invitation + raise exc + def __str__(self): return f"{self.email} invited to {self.domain}" diff --git a/src/backend/mailbox_manager/tests/api/invitations/test_api_domain_invitations_refresh.py b/src/backend/mailbox_manager/tests/api/invitations/test_api_domain_invitations_refresh.py new file mode 100644 index 0000000..9084863 --- /dev/null +++ b/src/backend/mailbox_manager/tests/api/invitations/test_api_domain_invitations_refresh.py @@ -0,0 +1,112 @@ +""" +Tests for MailDomainInvitations API endpoint in People's app mailbox_manager. +Focus on "refresh" action. +""" + +import pytest +from freezegun import freeze_time +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_domain_invitations__anonymous_cannot_refresh(): + """Anonymous are not allowed to refresh.""" + invitation = factories.MailDomainInvitationFactory() + response = APIClient().post( + f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/{invitation.id}/refresh/" + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_api_domain_invitations__no_access_cannot_see_invitation(): + """Users with no permission on the domain should be returned a 404 not found.""" + invitation = factories.MailDomainInvitationFactory() + + otro_access = factories.MailDomainAccessFactory( + role=enums.MailDomainRoleChoices.ADMIN + ) + + client = APIClient() + client.force_login(otro_access.user) + response = client.post( + f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/{invitation.id}/refresh/" + ) + assert response.status_code == status.HTTP_404_NOT_FOUND + + +def test_api_domain_invitations__viewer_cannot_refresh(): + """Viewers cannot refresh invitations.""" + invitation = factories.MailDomainInvitationFactory() + access = factories.MailDomainAccessFactory( + domain=invitation.domain, role=enums.MailDomainRoleChoices.VIEWER + ) + + client = APIClient() + client.force_login(access.user) + response = client.post( + f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/{invitation.id}/refresh/" + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + + +def test_api_domain_invitations__admins_can_refresh_expired_invitations(): + """Admins can refresh invitations.""" + with freeze_time("2025-12-16"): + invitation = factories.MailDomainInvitationFactory() + + access = factories.MailDomainAccessFactory( + domain=invitation.domain, role=enums.MailDomainRoleChoices.OWNER + ) + + assert invitation.is_expired is True # check invitation is correctly expired + client = APIClient() + client.force_login(access.user) + # can refresh expired invitations + response = client.post( + f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/{invitation.id}/refresh/" + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["is_expired"] is False + assert models.MailDomainInvitation.objects.count() == 1 + + invitation.refresh_from_db() + + # can also refresh valid + response = client.post( + f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/{invitation.id}/refresh/" + ) + assert response.status_code == status.HTTP_200_OK + assert response.json()["is_expired"] is False + assert models.MailDomainInvitation.objects.count() == 1 + + +def test_api_domain_invitations__refreshing_invitation_can_convert_to_access(): + """In the event of someone creating their account after their invitation expired, + said invitation has not been converted to access. + We don't want to create a new invitation but create an access instead.""" + with freeze_time("2025-12-16"): + invitation = factories.MailDomainInvitationFactory() + core_factories.UserFactory(email=invitation.email) + + access = factories.MailDomainAccessFactory( + domain=invitation.domain, role=enums.MailDomainRoleChoices.OWNER + ) + client = APIClient() + client.force_login(access.user) + response = client.post( + f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/{invitation.id}/refresh/" + ) + + assert response.status_code == status.HTTP_201_CREATED + assert ( + response.json()["detail"] + == "Email already known. Invitation not sent but access created instead." + ) + assert models.MailDomainAccess.objects.count() == 2 + assert not models.MailDomainInvitation.objects.exists()