diff --git a/CHANGELOG.md b/CHANGELOG.md index 2d5cfcd..fa04811 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to ## [Unreleased] +### Added + +- ✨(mailbox) allow to reset password on mailboxes #834 + ## [1.16.0] - 2025-05-05 ### Added diff --git a/src/backend/mailbox_manager/api/client/viewsets.py b/src/backend/mailbox_manager/api/client/viewsets.py index d9fc790..2eab329 100644 --- a/src/backend/mailbox_manager/api/client/viewsets.py +++ b/src/backend/mailbox_manager/api/client/viewsets.py @@ -293,6 +293,15 @@ class MailBoxViewSet( mailbox.save() return Response(serializers.MailboxSerializer(mailbox).data) + @action(detail=True, methods=["post"]) + def reset_password(self, request, domain_slug, pk=None): # pylint: disable=unused-argument + """Send a request to dimail to change password + and email new password to mailbox's secondary email.""" + mailbox = self.get_object() + dimail = DimailAPIClient() + dimail.reset_password(mailbox) + return Response(serializers.MailboxSerializer(mailbox).data) + class MailDomainInvitationViewset( mixins.CreateModelMixin, diff --git a/src/backend/mailbox_manager/tests/api/mailboxes/test_api_mailboxes_reset_password.py b/src/backend/mailbox_manager/tests/api/mailboxes/test_api_mailboxes_reset_password.py new file mode 100644 index 0000000..62b9d24 --- /dev/null +++ b/src/backend/mailbox_manager/tests/api/mailboxes/test_api_mailboxes_reset_password.py @@ -0,0 +1,183 @@ +""" +Unit tests for the reset password mailbox API +""" + +from unittest import mock + +from django.conf import settings + +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 +from mailbox_manager.tests.fixtures import dimail + +pytestmark = pytest.mark.django_db + + +def test_api_mailboxes__reset_password_anonymous_unauthorized(): + """Anonymous users should not be able to reset mailboxes password.""" + mailbox = factories.MailboxFactory(status=enums.MailboxStatusChoices.ENABLED) + response = APIClient().post( + f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/reset_password/", + ) + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +def test_api_mailboxes__reset_password_unrelated_forbidden(): + """Authenticated users not managing the domain + should not be able to reset its mailboxes password.""" + user = core_factories.UserFactory() + + client = APIClient() + client.force_login(user) + + mailbox = factories.MailboxFactory(status=enums.MailboxStatusChoices.ENABLED) + + response = client.post( + f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/reset_password/" + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +def test_api_mailboxes__reset_password_viewer_forbidden(): + """Domain viewers should not be able to reset passwords on mailboxes.""" + mailbox = factories.MailboxEnabledFactory() + viewer_access = factories.MailDomainAccessFactory( + role=enums.MailDomainRoleChoices.VIEWER, domain=mailbox.domain + ) + + client = APIClient() + client.force_login(viewer_access.user) + + response = client.post( + f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/reset_password/" + ) + assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.json() == { + "detail": "You do not have permission to perform this action." + } + + +def test_api_mailboxes__reset_password_no_secondary_email(): + """Should not try to reset password if no secondary email is specified.""" + mail_domain = factories.MailDomainEnabledFactory() + access = factories.MailDomainAccessFactory( + role=enums.MailDomainRoleChoices.OWNER, domain=mail_domain + ) + client = APIClient() + client.force_login(access.user) + + error = "Password reset requires a secondary email address. \ +Please add a valid secondary email before trying again." + + # Mailbox with no secondary email + mailbox = factories.MailboxEnabledFactory(domain=mail_domain, secondary_email=None) + response = client.post( + f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/{mailbox.pk}/reset_password/" + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == [error] + + # Mailbox with empty secondary email + mailbox = factories.MailboxEnabledFactory(domain=mail_domain, secondary_email="") + response = client.post( + f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/{mailbox.pk}/reset_password/" + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == [error] + + # Mailbox with primary email as secondary email + mailbox = factories.MailboxEnabledFactory(domain=mail_domain) + mailbox.secondary_email = str(mailbox) + mailbox.save() + response = client.post( + f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/{mailbox.pk}/reset_password/" + ) + assert response.status_code == status.HTTP_400_BAD_REQUEST + assert response.json() == [error] + + +@pytest.mark.parametrize( + "role", + [ + enums.MailDomainRoleChoices.OWNER, + enums.MailDomainRoleChoices.ADMIN, + ], +) +@responses.activate +def test_api_mailboxes__reset_password_admin_successful(role): + """Owner and admin users should be able to reset password on mailboxes. + New password should be sent to secondary email.""" + mail_domain = factories.MailDomainEnabledFactory() + mailbox = factories.MailboxEnabledFactory(domain=mail_domain) + + access = factories.MailDomainAccessFactory(role=role, domain=mail_domain) + client = APIClient() + client.force_login(access.user) + dimail_url = settings.MAIL_PROVISIONING_API_URL + + responses.add( + responses.POST, + f"{dimail_url}/domains/{mail_domain.name}/mailboxes/{mailbox.local_part}/reset_password/", + body=dimail.response_mailbox_created(str(mailbox)), + status=200, + ) + with mock.patch("django.core.mail.send_mail") as mock_send: + response = client.post( + f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/{mailbox.pk}/reset_password/" + ) + + assert mock_send.call_count == 1 + assert "Your password has been updated" in mock_send.mock_calls[0][1][1] + assert mock_send.mock_calls[0][1][3][0] == mailbox.secondary_email + + assert response.status_code == status.HTTP_200_OK + + +def test_api_mailboxes__reset_password_non_existing(): + """ + User gets a 404 when trying to reset password of mailbox which does not exist. + """ + user = core_factories.UserFactory() + client = APIClient() + client.force_login(user) + + response = client.get("/api/v1.0/mail-domains/nonexistent.domain/mailboxes/") + assert response.status_code == status.HTTP_404_NOT_FOUND + + +@responses.activate +def test_api_mailboxes__reset_password_connexion_failed(): + """ + No mail is sent when password reset failed because of connexion error. + """ + mail_domain = factories.MailDomainEnabledFactory() + mailbox = factories.MailboxEnabledFactory(domain=mail_domain) + + access = factories.MailDomainAccessFactory( + role=enums.MailDomainRoleChoices.OWNER, domain=mail_domain + ) + client = APIClient() + client.force_login(access.user) + + dimail_url = settings.MAIL_PROVISIONING_API_URL + responses.add( + responses.POST, + f"{dimail_url}/domains/{mail_domain.name}/mailboxes/{mailbox.local_part}/reset_password/", + body=ConnectionError(), + ) + + with pytest.raises(ConnectionError): + with mock.patch("django.core.mail.send_mail") as mock_send: + client.post( + f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/{mailbox.pk}/reset_password/" + ) + assert mock_send.call_count == 0 diff --git a/src/backend/mailbox_manager/utils/dimail.py b/src/backend/mailbox_manager/utils/dimail.py index 790d5c2..27f2474 100644 --- a/src/backend/mailbox_manager/utils/dimail.py +++ b/src/backend/mailbox_manager/utils/dimail.py @@ -1,3 +1,5 @@ +# pylint: disable=line-too-long + """A minimalist client to synchronize with mailbox provisioning API.""" import ast @@ -49,11 +51,20 @@ class DimailAPIClient: Return Bearer token. Requires MAIL_PROVISIONING_API_CREDENTIALS setting, to get a token from dimail /token/ endpoint. """ - response = requests.get( - f"{self.API_URL}/token/", - headers={"Authorization": f"Basic {self.API_CREDENTIALS}"}, - timeout=self.API_TIMEOUT, - ) + + try: + response = requests.get( + f"{self.API_URL}/token/", + headers={"Authorization": f"Basic {self.API_CREDENTIALS}"}, + 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_200_OK: headers = { @@ -254,35 +265,63 @@ class DimailAPIClient: Send email to confirm mailbox creation and send new mailbox information. """ + title = _("Your new mailbox information") + template_name = "new_mailbox" + self._send_mailbox_related_email( + title, template_name, recipient, mailbox_data, issuer + ) + + def notify_mailbox_password_reset(self, recipient, mailbox_data, issuer=None): + """ + Send email to notify of password reset + and send new password. + """ + title = _("Your password has been updated") + template_name = "reset_password" + self._send_mailbox_related_email( + title, template_name, recipient, mailbox_data, issuer + ) + + # pylint: disable=too-many-arguments + # pylint: disable=too-many-positional-arguments + def _send_mailbox_related_email( + self, title, template_name, recipient, mailbox_data, issuer=None + ): + """ + Send email with new mailbox or password reset information. + """ + + context = { + "title": title, + "site": Site.objects.get_current(), + "webmail_url": settings.WEBMAIL_URL, + "mailbox_data": mailbox_data, + } + try: with override(issuer.language if issuer else settings.LANGUAGE_CODE): - template_vars = { - "title": _("Your new mailbox information"), - "site": Site.objects.get_current(), - "webmail_url": settings.WEBMAIL_URL, - "mailbox_data": mailbox_data, - } - msg_html = render_to_string("mail/html/new_mailbox.html", template_vars) - msg_plain = render_to_string("mail/text/new_mailbox.txt", template_vars) mail.send_mail( - template_vars["title"], - msg_plain, + context["title"], + render_to_string(f"mail/text/{template_name}.txt", context), settings.EMAIL_FROM, [recipient], - html_message=msg_html, + html_message=render_to_string( + f"mail/html/{template_name}.html", context + ), fail_silently=False, ) + except smtplib.SMTPException as exception: + logger.error( + "Failed to send mailbox information to %s was not sent: %s", + recipient, + exception, + ) + else: logger.info( "Information for mailbox %s sent to %s.", mailbox_data["email"], recipient, ) - except smtplib.SMTPException as exception: - logger.error( - "Mailbox confirmation email to %s was not sent: %s", - recipient, - exception, - ) def import_mailboxes(self, domain): """Import mailboxes from dimail - open xchange in our database. @@ -561,3 +600,41 @@ class DimailAPIClient: exc_info=False, ) return [] + + def reset_password(self, mailbox): + """Send a request to reset mailbox password.""" + if not mailbox.secondary_email or mailbox.secondary_email == str(mailbox): + raise exceptions.ValidationError( + "Password reset requires a secondary email address. Please add a valid secondary email before trying again." + ) + + try: + response = session.post( + f"{self.API_URL}/domains/{mailbox.domain.name}/mailboxes/{mailbox.local_part}/reset_password/", + headers={"Authorization": f"Basic {self.API_CREDENTIALS}"}, + verify=True, + timeout=self.API_TIMEOUT, + ) + except requests.exceptions.ConnectionError as error: + logger.exception( + "Connection error while trying to reach %s.", + self.API_URL, + exc_info=error, + ) + raise error + + if response.status_code == status.HTTP_200_OK: + # send new password to secondary email + self.notify_mailbox_password_reset( + recipient=mailbox.secondary_email, + mailbox_data={ + "email": response.json()["email"], + "password": response.json()["password"], + }, + ) + logger.info( + "[DIMAIL] Password reset on mailbox %s.", + mailbox, + ) + return response + return self.raise_exception_for_unexpected_response(response) diff --git a/src/mail/mjml/new_mailbox.mjml b/src/mail/mjml/new_mailbox.mjml index 936415e..5dfdfc3 100644 --- a/src/mail/mjml/new_mailbox.mjml +++ b/src/mail/mjml/new_mailbox.mjml @@ -21,7 +21,7 @@ {% trans "Email address: "%}{{ mailbox_data.email }} - {% trans "Temporary password (to be modify on first login): "%}{{ mailbox_data.password }} + {% trans "Temporary password (to be modified on first login): "%}{{ mailbox_data.password }} diff --git a/src/mail/mjml/reset_password.mjml b/src/mail/mjml/reset_password.mjml new file mode 100644 index 0000000..a6a0635 --- /dev/null +++ b/src/mail/mjml/reset_password.mjml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + {% trans "Your password has been reset" %} + + {% trans "Your password has been reset." %} + {% trans "Please find below your new login information: " %} + + + + + {% trans "Email address: "%}{{ mailbox_data.email }} + {% trans "Temporary password (to be modified on first login): "%}{{ mailbox_data.password }} + + + + + + {% trans "Go to La Messagerie" %} + + + + {% trans "Sincerely," %} + {% trans "La Suite Team" %} + + + + + + + + +
{% trans "Sincerely," %}
{% trans "La Suite Team" %}