(mailboxes) send login links instead of passwords

Modify behaviour when creating mailbox or resetting password. We now send login
link to the secondary mailbox.
This commit is contained in:
Marie PUPO JEAMMET
2026-03-27 17:56:04 +01:00
committed by Marie
parent 480e313e3b
commit af622b223d
17 changed files with 565 additions and 69 deletions

View File

@@ -17,6 +17,10 @@ and this project adheres to
- 🐛(dimail) fix no import for functional mailboxes
- 🚸(mailboxes) improve error message when no secondary email
### Changed
- ✨(mailboxes) send login links instead of passwords
## [1.24.0] - 2026-03-24
### Fixed

View File

@@ -141,6 +141,7 @@ demo: ## flush db then create a demo for load testing purpose
@$(MAKE) resetdb
@$(MANAGE) create_demo
@$(MAKE) dimail-setup-db
@$(MAKE) migrate
.PHONY: demo

View File

@@ -4,7 +4,45 @@
The mailing solution provided in La Suite is [La Messagerie](https://webmail.numerique.gouv.fr/), using [Open-XChange](https://www.open-xchange.com/) (OX). OX not having a provisioning API, 'dimail-api' or 'dimail' was created to allow mail-provisioning through People.
API and its documentation can be found [here](https://api.dev.ox.numerique.gouv.fr/docs#/).
Dimail-api and its documentation can be found [here](https://api.osprod.dimail1.numerique.gouv.fr/docs#/).
## Features
### Domains
#### Domain creation
Upon creating a domain on People, the same domain is created on Messagerie and will undergo a series of checks. The DNS configuration provided by Messagerie can be found in domain administration menu.
Domains configuration is checked every hour. When Messagerie's checks return successful, the domain status in People is set to "enabled".
> [!NOTE]
> On Messagerie, domains belong to a group called "context". "Contexts" are shared spaces between domains, allowing users to discover colleagues not only on their domain but on their entire context.
> Contexts are only implemented in Messagerie and are not currently in use in People. Domains created via People are in their own context. Please contact our support team if you want several domains to be moved to a single context.
### Mailboxes
Mailboxes can be created by a domain owners or administrators in People's domain tab.
On enabled domains, mailboxes are created at the same time on dimail (and a confirmation email is sent to the secondary email).
On pending/failed domains, mailboxes are only created locally with "pending" status and are sent to dimail upon domain's (re)activation.
On disabled domains, mailboxes creation is not allowed.
## Permissions
Users can have 3 levels of permissions on a domain
| | on domain | on mailboxes |
| ----------------- | ------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------- |
| Owners | - promote administrators owners<br>- all of viewers and administrators' permissions | |
| Administrators | - create mailboxes<br>- invite users to manage domain<br>- promote viewers to administrators<br>- all viewers permissions | - deactivate mailbox<br>- send login link<br>- update information |
| Viewers | - see the domain's information<br>- list its mailboxes<br> | - update information on own mailbox<br>- send login link |
| No role | Cannot see domain. Requests return a 404_NOT_FOUND | - none |
| Not authenticated | Cannot see domain. Requests return a 401_NOT_AUTHENTICATED <br> | - none |
## For devs
## Use of dimail container

View File

@@ -327,7 +327,7 @@ def test_matrix_webhook__kick_user_from_room_success(caplog):
@responses.activate
def test_matrix_webhook__kick_user_from_room_forbidden(caplog):
"""Cannot kick an admin."""
caplog.set_level(logging.INFO)
caplog.set_level(logging.ERROR)
user = factories.UserFactory()
webhook = factories.TeamWebhookFactory(

View File

@@ -2,6 +2,7 @@
from logging import getLogger
from django.conf import settings
from django.contrib.auth.hashers import make_password
from django.core import exceptions as django_exceptions
from django.shortcuts import get_object_or_404
@@ -63,19 +64,25 @@ class MailboxSerializer(serializers.ModelSerializer):
mailbox.set_password(mailbox_data["password"])
mailbox.save()
if mailbox.secondary_email:
# send confirmation email
client.notify_mailbox_creation(
recipient=mailbox.secondary_email,
mailbox_data=response.json(),
issuer=self.context["request"].user,
)
else:
if not mailbox.secondary_email:
logger.warning(
"Email notification for %s creation not sent "
"because no secondary email found",
mailbox,
)
else:
mailbox_data["link"] = (
f"{client.API_URL}/code/{client.get_login_code(mailbox)}"
)
if not settings.SEND_MAILBOX_PASSWORD:
mailbox_data.pop("password")
client.notify_mailbox_creation(
recipient=mailbox.secondary_email,
mailbox_data=mailbox_data,
issuer=self.context["request"].user,
)
return mailbox

View File

@@ -261,6 +261,9 @@ class MailBoxViewSet(
POST /api/<version>/mail-domains/<domain_slug>/mailboxes/<mailbox_id>/reset/
Send a request to mail-provider to reset password.
POST /api/<version>/mail-domains/<domain_slug>/mailboxes/<mailbox_id>/login_link/
Get one-time login link from mail provider and send it to secondary mailbox
PUT /api/<version>/mail-domains/<domain_slug>/mailboxes/<mailbox_id>/
Send a request to update mailbox. Cannot modify domain or local_part.
@@ -345,6 +348,14 @@ class MailBoxViewSet(
dimail.reset_password(mailbox)
return Response(serializers.MailboxSerializer(mailbox).data)
@action(detail=True, methods=["post"])
def login_link(self, request, domain_slug, pk=None): # pylint: disable=unused-argument
"""Request dimail for a connexion code and email it to mailbox's secondary email."""
mailbox = self.get_object()
dimail = DimailAPIClient()
dimail.send_login_link(mailbox)
return Response(serializers.MailboxSerializer(mailbox).data)
class MailDomainInvitationViewset(
mixins.CreateModelMixin,

View File

@@ -4,6 +4,7 @@ Unit tests for the mailbox API
# pylint: disable=W0613, C0302
import json
import logging
import re
from logging import Logger
from unittest import mock
@@ -20,10 +21,9 @@ from core import factories as core_factories
from mailbox_manager import enums, factories, models
from mailbox_manager.api.client import serializers
from mailbox_manager.tests.fixtures.dimail import (
response_mailbox_created,
)
from mailbox_manager.tests.fixtures import dimail as dimail_responses
logger = logging.getLogger(__name__)
pytestmark = pytest.mark.django_db
@@ -125,7 +125,7 @@ def test_api_mailboxes__create_display_name_no_constraint_on_different_domains(
responses.add(
responses.POST,
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body=response_mailbox_created(
body=dimail_responses.response_mailbox_created(
f"{new_mailbox_data['local_part']}@{access.domain.name}"
),
status=status.HTTP_201_CREATED,
@@ -162,13 +162,18 @@ def test_api_mailboxes__create_roles_success(role, dimail_token_ok, mailbox_data
# token response in fixtures
responses.add(
responses.POST,
re.compile(rf".*/domains/{mail_domain.name}/mailboxes/"),
body=response_mailbox_created(
re.compile(
rf".*/domains/{mail_domain.name}/mailboxes/{mailbox_data['local_part']}$"
),
body=dimail_responses.response_mailbox_created(
f"{mailbox_data['local_part']}@{mail_domain.name}"
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
dimail_responses.response_login_code_ok(
mail_domain.name, mailbox_data["local_part"]
)
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
mailbox_data,
@@ -213,12 +218,13 @@ def test_api_mailboxes__create_with_accent_success(role, dimail_token_ok):
responses.add(
responses.POST,
re.compile(rf".*/domains/{mail_domain.name}/mailboxes/"),
body=response_mailbox_created(
body=dimail_responses.response_mailbox_created(
f"{mailbox_values['local_part']}@{mail_domain.name}"
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
dimail_responses.response_login_code_ok(mail_domain, mailbox_values["local_part"])
response = client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/",
mailbox_values,
@@ -249,14 +255,19 @@ def test_api_mailboxes__create_lowercase(dimail_token_ok, mailbox_data):
# token response in fixtures
responses.add(
responses.POST,
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body=response_mailbox_created(
re.compile(
rf".*/domains/{access.domain.name}/mailboxes/{mailbox_data['local_part'].lower()}"
),
body=dimail_responses.response_mailbox_created(
f"{mailbox_data['local_part']}@{access.domain.name}"
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
dimail_responses.response_login_code_ok(
access.domain, mailbox_data["local_part"].lower()
)
client = APIClient()
client.force_login(access.user)
response = client.post(
@@ -317,7 +328,7 @@ def test_api_mailboxes__create_without_secondary_email(role, caplog, dimail_toke
responses.add(
responses.POST,
re.compile(rf".*/domains/{mail_domain.name}/mailboxes/"),
body=response_mailbox_created(
body=dimail_responses.response_mailbox_created(
f"{mailbox_values['local_part']}@{mail_domain.name}"
),
status=status.HTTP_201_CREATED,
@@ -418,12 +429,13 @@ def test_api_mailboxes__same_local_part_on_different_domains(dimail_token_ok):
responses.add(
responses.POST,
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body=response_mailbox_created(
body=dimail_responses.response_mailbox_created(
f"{mailbox_values['local_part']}@{access.domain.name}"
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
dimail_responses.response_login_code_ok(access.domain, existing_mailbox.local_part)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
mailbox_values,
@@ -469,7 +481,7 @@ def test_api_mailboxes__create_pending_mailboxes(domain_status, mailbox_data):
@responses.activate
def test_api_mailboxes__existing_alias_ok(mailbox_data, dimail_token_ok):
def test_api_mailboxes__no_conflict_existing_alias_ok(mailbox_data, dimail_token_ok):
"""Can create mailbox even if local_part is already used by an alias."""
alias = factories.AliasFactory()
access = factories.MailDomainAccessFactory(
@@ -481,12 +493,13 @@ def test_api_mailboxes__existing_alias_ok(mailbox_data, dimail_token_ok):
responses.post(
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body=response_mailbox_created(
f"{mailbox_data['local_part']}@{access.domain.name}"
body=dimail_responses.response_mailbox_created(
f"{alias.local_part}@{access.domain.name}"
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
dimail_responses.response_login_code_ok(alias.domain, alias.local_part)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
@@ -594,13 +607,13 @@ def test_api_mailboxes__domain_owner_or_admin_successful_creation_and_provisioni
responses.add(
responses.POST,
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body=response_mailbox_created(
body=dimail_responses.response_mailbox_created(
f"{mailbox_data['local_part']}@{access.domain.name}"
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
dimail_responses.response_login_code_ok(access.domain, mailbox_data["local_part"])
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
mailbox_data,
@@ -652,13 +665,13 @@ def test_api_mailboxes__domain_owner_or_admin_successful_creation_sets_password(
responses.add(
responses.POST,
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body=response_mailbox_created(
body=dimail_responses.response_mailbox_created(
f"{mailbox_data['local_part']}@{access.domain.name}"
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
dimail_responses.response_login_code_ok(access.domain, mailbox_data["local_part"])
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
mailbox_data,
@@ -943,13 +956,13 @@ def test_api_mailboxes__send_correct_logger_infos(
responses.add(
responses.POST,
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body=response_mailbox_created(
body=dimail_responses.response_mailbox_created(
f"{mailbox_data['local_part']}@{access.domain.name}"
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
dimail_responses.response_login_code_ok(access.domain, mailbox_data["local_part"])
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
mailbox_data,
@@ -981,23 +994,27 @@ def test_api_mailboxes__sends_new_mailbox_notification(
Creating a new mailbox should send confirmation email
to secondary email.
"""
user = core_factories.UserFactory(language="fr-fr")
access = factories.MailDomainAccessFactory(
user=user,
user=core_factories.UserFactory(language="en-us"),
role=enums.MailDomainRoleChoices.OWNER,
)
client = APIClient()
client.force_login(user)
client.force_login(access.user)
# Ensure successful response using "responses":
# token response in fixtures
responses.add(
responses.POST,
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body=response_mailbox_created(f"{mailbox_data['local_part']}@{access.domain}"),
body=dimail_responses.response_mailbox_created(
f"{mailbox_data['local_part']}@{access.domain}"
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
dimail_responses.response_login_code_ok(access.domain, mailbox_data["local_part"])
with mock.patch("django.core.mail.send_mail") as mock_send:
client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
@@ -1006,7 +1023,64 @@ def test_api_mailboxes__sends_new_mailbox_notification(
)
assert mock_send.call_count == 1
assert "Informations sur votre nouvelle boîte mail" in mock_send.mock_calls[0][1][1]
assert "Your new mailbox information" in mock_send.mock_calls[0][1][1]
assert "OneTimeCode" in mock_send.mock_calls[0][1][1]
assert "password" not in mock_send.mock_calls[0][1][1]
assert mock_send.mock_calls[0][1][3][0] == mailbox_data["secondary_email"]
expected_messages = {
(
"Information for mailbox %s sent to %s.",
f"{mailbox_data['local_part']}@{access.domain.name}",
mailbox_data["secondary_email"],
)
}
actual_messages = {args for args, _ in mock_info.call_args_list}
assert expected_messages.issubset(actual_messages)
@responses.activate
@override_settings(SEND_MAILBOX_PASSWORD=True)
@mock.patch.object(Logger, "info")
def test_api_mailboxes__sends_new_mailbox_notification_with_password(
mock_info, dimail_token_ok, mailbox_data
):
"""
Creating a new mailbox should send confirmation email
to secondary email.
"""
access = factories.MailDomainAccessFactory(
user=core_factories.UserFactory(language="en-us"),
role=enums.MailDomainRoleChoices.OWNER,
)
client = APIClient()
client.force_login(access.user)
# Ensure successful response using "responses":
# token response in fixtures
responses.add(
responses.POST,
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body=dimail_responses.response_mailbox_created(
f"{mailbox_data['local_part']}@{access.domain}"
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
dimail_responses.response_login_code_ok(access.domain, mailbox_data["local_part"])
with mock.patch("django.core.mail.send_mail") as mock_send:
client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
mailbox_data,
format="json",
)
assert mock_send.call_count == 1
assert "Your new mailbox information" in mock_send.mock_calls[0][1][1]
assert "OneTimeCode" in mock_send.mock_calls[0][1][1]
assert "password" in mock_send.mock_calls[0][1][1]
assert mock_send.mock_calls[0][1][3][0] == mailbox_data["secondary_email"]
expected_messages = {

View File

@@ -0,0 +1,183 @@
"""
Unit tests for the login link endpoint
"""
import logging
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 as dimail_responses
pytestmark = pytest.mark.django_db
def test_api_mailboxes__login_link_anonymous_unauthorized():
"""Anonymous users should not be able to ask for a login link."""
mailbox = factories.MailboxFactory(status=enums.MailboxStatusChoices.ENABLED)
response = APIClient().post(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/login_link/",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_mailboxes__login_link_no_access_forbidden_not_found():
"""Authenticated users not managing the domain
should not be able to get login link for any mailbox."""
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}/login_link/"
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {
"detail": "No MailDomain matches the given query.",
}
def test_api_mailboxes__login_link_viewer_forbidden():
"""Domain viewers should not be able to get login link on mailboxes."""
viewer_access = factories.MailDomainAccessFactory(
role=enums.MailDomainRoleChoices.VIEWER
)
client = APIClient()
client.force_login(viewer_access.user)
# another user's mailbox
mailbox = factories.MailboxEnabledFactory(domain=viewer_access.domain)
response = client.post(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/login_link/"
)
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__login_link_no_secondary_email():
"""Should not try to get login link 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 = "Logging in with a link 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}/login_link/"
)
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}/login_link/"
)
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}/login_link/"
)
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__login_link_admin_successful(role, dimail_token_ok, caplog): # pylint: disable=W0613
"""Owner and admin users should be able to request login link for any mailboxes.
Login links should be sent to secondary email."""
caplog.set_level(logging.INFO)
mailbox = factories.MailboxEnabledFactory()
access = factories.MailDomainAccessFactory(role=role, domain=mailbox.domain)
client = APIClient()
client.force_login(access.user)
dimail_responses.response_login_code_ok(mailbox.domain, mailbox.local_part)
with mock.patch("django.core.mail.send_mail") as mock_send:
response = client.post(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/login_link/"
)
assert mock_send.call_count == 1
assert "Here is your login link" in mock_send.mock_calls[0][1][1]
assert "http://dimail:8000/code/OneTimeCode" in mock_send.mock_calls[0][1][1]
assert mailbox.password not 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__login_link_non_existing():
"""
User gets a 404 when trying to get login link of a 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__login_link_connexion_failed(dimail_token_ok): # pylint: disable=W0613
"""
No mail is sent when login link request 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
# token response in fixtures
responses.add(
responses.POST,
f"{dimail_url}/domains/{mail_domain.name}/mailboxes/{mailbox.local_part}/code/",
body=ConnectionError(),
)
with mock.patch("django.core.mail.send_mail") as mock_send:
with pytest.raises(ConnectionError):
client.post(
f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/{mailbox.pk}/login_link/"
)
assert mock_send.call_count == 0

View File

@@ -2,6 +2,10 @@
"""Define here some fake data from dimail, useful to mock dimail response"""
import json
import re
import responses
from rest_framework import status
## USERS
@@ -17,6 +21,24 @@ def response_user_created(user_sub):
)
def response_login_code_ok(domain, local_part):
"""Mock dimail's response when requesting login code."""
return responses.add(
responses.POST,
re.compile(rf".*/domains/{domain}/mailboxes/{local_part}/code"),
json={
"email": f"{local_part}@{domain}",
"code": "OneTimeCode",
"expires_at": 1774900909,
"expires_in": 3599,
"maxuse": 1,
"nbuse": 0,
},
status=status.HTTP_200_OK,
content_type="application/json",
)
## DOMAINS
CHECK_DOMAIN_BROKEN = {
"name": "example.fr",
@@ -298,3 +320,24 @@ def response_allows_created(user_name, domain_name):
def response_mailbox_created(email_address):
"""mimic dimail response upon successful mailbox creation."""
return json.dumps({"email": email_address.lower(), "password": "password"})
# Fixture
def response_connexion_code(email):
"""Mock dimail response when /token/ endpoit is given valid credentials."""
local_part, domain = email.split("@")
return responses.post(
re.compile(rf".*/domains/{domain}/mailboxes/{local_part}/code"),
json={
"email": "string",
"code": "string",
"expires_at": 0,
"expires_in": 0,
"maxuse": 0,
"nbuse": 0,
},
status=status.HTTP_200_OK,
content_type="application/json",
)

View File

@@ -290,19 +290,23 @@ class DimailAPIClient:
def notify_mailbox_creation(self, recipient, mailbox_data, issuer=None):
"""
Send email to confirm mailbox creation
and send new mailbox information.
Send email to confirm mailbox creation, provide mailbox information
and connexion url.
"""
title = _("Your new mailbox information")
template_name = "new_mailbox"
if settings.SEND_MAILBOX_PASSWORD and "password" in mailbox_data:
template_name = "new_mailbox_password"
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.
Send email to notify of password reset and send new password.
"""
title = _("Your password has been updated")
template_name = "reset_password"
@@ -310,6 +314,29 @@ class DimailAPIClient:
title, template_name, recipient, mailbox_data, issuer
)
def send_login_link(self, mailbox, issuer=None):
"""
Send magic link to secondary email.
"""
title = _("Here is your login link")
template_name = "send_login_link"
recipient = mailbox.secondary_email
login_link = f"{self.API_URL}/code/{self.get_login_code(mailbox)}"
self._send_mailbox_related_email(
title,
template_name,
recipient,
{
"email": str(mailbox),
"link": login_link,
},
issuer,
)
logger.info(
"[DIMAIL] Login link for mailbox %s sent to %s.", mailbox, recipient
)
# pylint: disable=too-many-arguments
# pylint: disable=too-many-positional-arguments
def _send_mailbox_related_email(
@@ -318,7 +345,6 @@ class DimailAPIClient:
"""
Send email with new mailbox or password reset information.
"""
context = {
"title": title,
"site": Site.objects.get_current(),
@@ -351,6 +377,32 @@ class DimailAPIClient:
recipient,
)
def get_login_code(self, mailbox):
"""Get a login code from dimail."""
if not mailbox.secondary_email or mailbox.secondary_email == str(mailbox):
raise exceptions.ValidationError(
"Logging in with a link 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}/code/",
json={"expires_in": 3600, "maxuse": 1},
headers=self._get_headers(),
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
response.raise_for_status()
return response.json()["code"]
def import_mailboxes(self, domain):
"""Import mailboxes from dimail - open xchange in our database.
This is useful in case of acquisition of a pre-existing mail domain.
@@ -671,6 +723,7 @@ class DimailAPIClient:
raise error
if response.status_code == status.HTTP_200_OK:
mailbox.password = response.json()["password"]
# send new password to secondary email
self._notify_mailbox_password_reset(
recipient=mailbox.secondary_email,

View File

@@ -636,6 +636,12 @@ class Base(Configuration):
environ_prefix=None,
)
SEND_MAILBOX_PASSWORD = values.Value(
default=False,
environ_name="SEND_MAILBOX_PASSWORD",
environ_prefix=None,
)
# pylint: disable=invalid-name
@property
def ENVIRONMENT(self):

View File

@@ -10,17 +10,17 @@ export interface DisableMailboxParams {
isEnabled: boolean;
}
export interface ResetPasswordParams {
export interface LoginLinkParams {
mailDomainSlug: string;
mailboxId: string;
}
export const resetPassword = async ({
export const loginLink = async ({
mailDomainSlug,
mailboxId,
}: ResetPasswordParams): Promise<void> => {
}: LoginLinkParams): Promise<void> => {
const response = await fetchAPI(
`mail-domains/${mailDomainSlug}/mailboxes/${mailboxId}/reset_password/`,
`mail-domains/${mailDomainSlug}/mailboxes/${mailboxId}/login_link/`,
{
method: 'POST',
},
@@ -28,16 +28,16 @@ export const resetPassword = async ({
if (!response.ok) {
throw new APIError(
'Failed to reset mailbox password',
'Failed to send login link.',
await errorCauses(response),
);
}
};
export const useResetPassword = () => {
export const useLoginLink = () => {
const queryClient = useQueryClient();
return useMutation<void, APIError, ResetPasswordParams>({
mutationFn: resetPassword,
return useMutation<void, APIError, LoginLinkParams>({
mutationFn: loginLink,
onSuccess: (_data, variables) => {
void queryClient.invalidateQueries({
queryKey: [

View File

@@ -17,7 +17,7 @@ import {
} from '@/features/mail-domains/mailboxes';
import {
useResetPassword,
useLoginLink,
useUpdateMailboxStatus,
} from '../../api/useUpdateMailboxStatus';
@@ -41,7 +41,7 @@ export const PanelActions = ({ mailDomain, mailbox }: PanelActionsProps) => {
const { toast } = useToastProvider();
const { mutate: updateMailboxStatus } = useUpdateMailboxStatus();
const { mutate: resetPassword } = useResetPassword();
const { mutate: loginLink } = useLoginLink();
const handleUpdateMailboxStatus = () => {
disableModal.close();
@@ -58,18 +58,18 @@ export const PanelActions = ({ mailDomain, mailbox }: PanelActionsProps) => {
);
};
const handleResetMailboxPassword = () => {
resetPassword(
const handleMailboxLoginLink = () => {
loginLink(
{
mailDomainSlug: mailDomain.slug,
mailboxId: mailbox.id,
},
{
onSuccess: () =>
toast(t('Successfully reset password.'), VariantType.SUCCESS),
toast(t('Login link successfully sent.'), VariantType.SUCCESS),
onError: (error) =>
toast(
t(error.cause?.[0] || 'Failed to reset password'),
t(error.cause?.[0] || 'Failed to send login link'),
VariantType.ERROR,
),
},
@@ -128,7 +128,7 @@ export const PanelActions = ({ mailDomain, mailbox }: PanelActionsProps) => {
},
},
{
label: t('Reset password'),
label: t('Send login link'),
icon: (
<Icon
iconName={isEnabled ? 'lock_reset' : 'block'}
@@ -142,7 +142,7 @@ export const PanelActions = ({ mailDomain, mailbox }: PanelActionsProps) => {
if (!isEnabled) {
return;
}
handleResetMailboxPassword();
handleMailboxLoginLink();
},
},
]}

View File

@@ -1,38 +1,35 @@
<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-image align="center" src="{% base64_static 'images/messagerie.png' %}" width="60px" height="60px" alt="{% trans 'Messagerie' %}" />
</mj-column>
</mj-section>
<mj-section mj-class="bg--white-100" padding="0px 20px">
<mj-column>
<!-- Welcome Message -->
<mj-text font-size="30px"><b>{% trans "Welcome to La Messagerie" %}</b></mj-text>
<mj-text>{% trans "La Messagerie is the email solution of La Suite." %}</mj-text>
<mj-text font-size="30px"><b>{% trans "Welcome to Messagerie" %}!</b></mj-text>
<mj-text>{% trans "Messagerie is the email solution of LaSuite." %}</mj-text>
<!-- Main Message -->
<mj-text>{% trans "Your mailbox has been created." %}</mj-text>
<mj-text>{% trans "Please find below your login info: " %}</mj-text>
<mj-text>{% trans "Your mailbox has been created" %}. {% trans "Please find below your login info and login link" %}:</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 background-color="#000091" color="white" href="{{ mailbox_data.link }}">
{% trans "Go to your new mailbox" %}
</mj-button>
<!-- Signature -->
<mj-text>
<p>{% trans "Sincerely," %}</p>
<p>{% trans "La Suite Team" %}</p>
<p>{% trans "LaSuite Team" %}</p>
</mj-text>
</mj-column>
</mj-section>

View File

@@ -0,0 +1,42 @@
<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 'Messagerie' %}" />
</mj-column>
</mj-section>
<mj-section mj-class="bg--white-100" padding="0px 20px">
<mj-column>
<!-- Welcome Message -->
<mj-text font-size="30px"><b>{% trans "Welcome to Messagerie" %}!</b></mj-text>
<mj-text>{% trans "Messagerie is the email solution of LaSuite." %}</mj-text>
<!-- Main Message -->
<mj-text>{% trans "Your mailbox has been created" %}. {% trans "Please find below your login info and login link" %}:</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 "Password: "%}<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="{{ mailbox_data.link }}">
{% trans "Go to your new mailbox" %}
</mj-button>
<!-- Signature -->
<mj-text>
<p>{% trans "Sincerely," %}</p>
<p>{% trans "LaSuite Team" %}</p>
</mj-text>
</mj-column>
</mj-section>
</mj-wrapper>
</mj-body>
<mj-include path="./partial/footer.mjml" />
</mjml>

View File

@@ -5,7 +5,7 @@
<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-image align="center" src="{% base64_static 'images/messagerie.png' %}" width="60px" height="60px" alt="{% trans 'Messagerie' %}" />
</mj-column>
</mj-section>
<mj-section mj-class="bg--white-100" padding="0px 20px">
@@ -26,12 +26,12 @@
<mj-section padding="0px 20px">
<mj-column>
<mj-button background-color="#000091" color="white" href="{{ webmail_url }}">
{% trans "Go to La Messagerie" %}
{% trans "Go to Messagerie" %}
</mj-button>
<!-- Signature -->
<mj-text>
<p>{% trans "Sincerely," %}</p>
<p>{% trans "La Suite Team" %}</p>
<p>{% trans "LaSuite Team" %}</p>
</mj-text>
</mj-column>
</mj-section>

View File

@@ -0,0 +1,37 @@
<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 '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 "Here is your login link." %}</b></mj-text>
<!-- Main Message -->
<mj-text>{% trans "Please find below your temporary one-time login link for your address " %}<b>{{ mailbox_data.email }}</b> :</mj-text>
<mj-text>{{ mailbox_data.link }}</mj-text>
</mj-column>
</mj-section>
<mj-section padding="0px 20px">
<mj-column>
<mj-button background-color="#000091" color="white" href="{{ mailbox_data.link }}">
{% trans "Go to Messagerie" %}
</mj-button>
<!-- Signature -->
<mj-text>
<p>{% trans "Sincerely," %}</p>
<p>{% trans "LaSuite Team" %}</p>
</mj-text>
</mj-column>
</mj-section>
</mj-wrapper>
</mj-body>
<mj-include path="./partial/footer.mjml" />
</mjml>