✨(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]
|
||||
|
||||
### Added
|
||||
|
||||
- ✨(mailbox) allow to reset password on mailboxes #834
|
||||
|
||||
## [1.16.0] - 2025-05-05
|
||||
|
||||
### Added
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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."""
|
||||
|
||||
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)
|
||||
|
||||
@@ -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">
|
||||
|
||||
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