✨(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:
committed by
Marie
parent
b5d86967ff
commit
2d56c57102
@@ -8,6 +8,10 @@ and this project adheres to
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
### Added
|
||||||
|
|
||||||
|
- ✨(mailbox) allow to reset password on mailboxes #834
|
||||||
|
|
||||||
## [1.16.0] - 2025-05-05
|
## [1.16.0] - 2025-05-05
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|||||||
@@ -293,6 +293,15 @@ class MailBoxViewSet(
|
|||||||
mailbox.save()
|
mailbox.save()
|
||||||
return Response(serializers.MailboxSerializer(mailbox).data)
|
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(
|
class MailDomainInvitationViewset(
|
||||||
mixins.CreateModelMixin,
|
mixins.CreateModelMixin,
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
|
# pylint: disable=line-too-long
|
||||||
|
|
||||||
"""A minimalist client to synchronize with mailbox provisioning API."""
|
"""A minimalist client to synchronize with mailbox provisioning API."""
|
||||||
|
|
||||||
import ast
|
import ast
|
||||||
@@ -49,11 +51,20 @@ class DimailAPIClient:
|
|||||||
Return Bearer token. Requires MAIL_PROVISIONING_API_CREDENTIALS setting,
|
Return Bearer token. Requires MAIL_PROVISIONING_API_CREDENTIALS setting,
|
||||||
to get a token from dimail /token/ endpoint.
|
to get a token from dimail /token/ endpoint.
|
||||||
"""
|
"""
|
||||||
response = requests.get(
|
|
||||||
f"{self.API_URL}/token/",
|
try:
|
||||||
headers={"Authorization": f"Basic {self.API_CREDENTIALS}"},
|
response = requests.get(
|
||||||
timeout=self.API_TIMEOUT,
|
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:
|
if response.status_code == status.HTTP_200_OK:
|
||||||
headers = {
|
headers = {
|
||||||
@@ -254,35 +265,63 @@ class DimailAPIClient:
|
|||||||
Send email to confirm mailbox creation
|
Send email to confirm mailbox creation
|
||||||
and send new mailbox information.
|
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:
|
try:
|
||||||
with override(issuer.language if issuer else settings.LANGUAGE_CODE):
|
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(
|
mail.send_mail(
|
||||||
template_vars["title"],
|
context["title"],
|
||||||
msg_plain,
|
render_to_string(f"mail/text/{template_name}.txt", context),
|
||||||
settings.EMAIL_FROM,
|
settings.EMAIL_FROM,
|
||||||
[recipient],
|
[recipient],
|
||||||
html_message=msg_html,
|
html_message=render_to_string(
|
||||||
|
f"mail/html/{template_name}.html", context
|
||||||
|
),
|
||||||
fail_silently=False,
|
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(
|
logger.info(
|
||||||
"Information for mailbox %s sent to %s.",
|
"Information for mailbox %s sent to %s.",
|
||||||
mailbox_data["email"],
|
mailbox_data["email"],
|
||||||
recipient,
|
recipient,
|
||||||
)
|
)
|
||||||
except smtplib.SMTPException as exception:
|
|
||||||
logger.error(
|
|
||||||
"Mailbox confirmation email to %s was not sent: %s",
|
|
||||||
recipient,
|
|
||||||
exception,
|
|
||||||
)
|
|
||||||
|
|
||||||
def import_mailboxes(self, domain):
|
def import_mailboxes(self, domain):
|
||||||
"""Import mailboxes from dimail - open xchange in our database.
|
"""Import mailboxes from dimail - open xchange in our database.
|
||||||
@@ -561,3 +600,41 @@ class DimailAPIClient:
|
|||||||
exc_info=False,
|
exc_info=False,
|
||||||
)
|
)
|
||||||
return []
|
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)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@
|
|||||||
<mj-section background-color="#f3f2fe" padding="0px 20px">
|
<mj-section background-color="#f3f2fe" padding="0px 20px">
|
||||||
<mj-column>
|
<mj-column>
|
||||||
<mj-text>{% trans "Email address: "%}<b>{{ mailbox_data.email }}</b></mj-text>
|
<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-column>
|
||||||
</mj-section>
|
</mj-section>
|
||||||
<mj-section padding="0px 20px">
|
<mj-section padding="0px 20px">
|
||||||
|
|||||||
43
src/mail/mjml/reset_password.mjml
Normal file
43
src/mail/mjml/reset_password.mjml
Normal 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>
|
||||||
|
|
||||||
Reference in New Issue
Block a user