(dimail) add reset password method

allow domain owner and admins to reset password for a mailbox
they manage. The request is sent to dimail, which responds with
a new randomly generated password. This new password is sent to
secondary email.
This commit is contained in:
Marie PUPO JEAMMET
2025-03-21 18:13:10 +01:00
committed by Marie
parent b5d86967ff
commit 2d56c57102
6 changed files with 339 additions and 23 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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)

View File

@@ -21,7 +21,7 @@
<mj-section background-color="#f3f2fe" padding="0px 20px">
<mj-column>
<mj-text>{% trans "Email address: "%}<b>{{ mailbox_data.email }}</b></mj-text>
<mj-text>{% trans "Temporary password (to be modify on first login): "%}<b>{{ mailbox_data.password }}</b></mj-text>
<mj-text>{% trans "Temporary password (to be modified on first login): "%}<b>{{ mailbox_data.password }}</b></mj-text>
</mj-column>
</mj-section>
<mj-section padding="0px 20px">

View File

@@ -0,0 +1,43 @@
<mjml>
<mj-include path="./partial/header.mjml" />
<mj-body mj-class="bg--blue-100">
<mj-wrapper css-class="wrapper" padding="0 40px 40px 40px">
<mj-section>
<mj-column>
<mj-image align="center" src="{% base64_static 'images/messagerie.png' %}" width="60px" height="60px" alt="{% trans 'La Messagerie' %}" />
</mj-column>
</mj-section>
<mj-section mj-class="bg--white-100" padding="0px 20px">
<mj-column>
<!-- Message -->
<mj-text font-size="30px"><b>{% trans "Your password has been reset" %}</b></mj-text>
<!-- Main Message -->
<mj-text>{% trans "Your password has been reset." %}</mj-text>
<mj-text>{% trans "Please find below your new login information: " %}</mj-text>
</mj-column>
</mj-section>
<mj-section background-color="#f3f2fe" padding="0px 20px">
<mj-column>
<mj-text>{% trans "Email address: "%}<b>{{ mailbox_data.email }}</b></mj-text>
<mj-text>{% trans "Temporary password (to be modified on first login): "%}<b>{{ mailbox_data.password }}</b></mj-text>
</mj-column>
</mj-section>
<mj-section padding="0px 20px">
<mj-column>
<mj-button background-color="#000091" color="white" href="{{ webmail_url }}">
{% trans "Go to La Messagerie" %}
</mj-button>
<!-- Signature -->
<mj-text>
<p>{% trans "Sincerely," %}</p>
<p>{% trans "La Suite Team" %}</p>
</mj-text>
</mj-column>
</mj-section>
</mj-wrapper>
</mj-body>
<mj-include path="./partial/footer.mjml" />
</mjml>