mirror of
https://github.com/suitenumerique/people
synced 2026-04-25 17:15:13 +02:00
✨(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:
committed by
Marie
parent
480e313e3b
commit
af622b223d
@@ -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
|
||||
|
||||
1
Makefile
1
Makefile
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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();
|
||||
},
|
||||
},
|
||||
]}
|
||||
|
||||
@@ -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>
|
||||
|
||||
42
src/mail/mjml/new_mailbox_password.mjml
Normal file
42
src/mail/mjml/new_mailbox_password.mjml
Normal 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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
37
src/mail/mjml/send_login_link.mjml
Normal file
37
src/mail/mjml/send_login_link.mjml
Normal 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>
|
||||
|
||||
Reference in New Issue
Block a user