Compare commits

...

27 Commits

Author SHA1 Message Date
Marie PUPO JEAMMET 9f85e66abe ♻️(refacto) refacto responses
WIP
2026-01-29 18:24:11 +01:00
Marie PUPO JEAMMET 6ae195b90c 🔥(cleanup) remove comment from permissions file
remote remaining commented line from previous PR's tests
2026-01-28 14:49:31 +00:00
Marie PUPO JEAMMET be64abb22f (invitations) can delete domain invitations
add delete method to domain viewset
2026-01-28 14:49:31 +00:00
mjeammet ca56eb0cac 🌐(i18n) update translated strings
Update translated files with new translations
2026-01-26 11:30:57 +00:00
Marie PUPO JEAMMET 99ca34719f 🔖(patch) release version 1.22.2
Update all version files and changelog for patch release.
2026-01-26 11:30:57 +00:00
renovate[bot] 014fb62f95 ⬆️(dependencies) update lodash to v4.17.23 [SECURITY] 2026-01-26 10:48:52 +00:00
Marie PUPO JEAMMET 99433a6722 🐛(aliases) alias destination can be devnull@devnull
devnull@devnull is not considered a valid email address by django's
EmailFieldValidator but it's a special address in dimail's config.

Make "destination" a CharField instead of an EmailField to replace
validator and add devnull to allowlist.
2026-01-23 17:56:53 +00:00
Marie PUPO JEAMMET 5ebc88bcff 🔖(patch) release version 1.22.1
Update all version files and changelog for patch release.
2026-01-22 11:10:44 +00:00
Quentin BEY a65e61bd96 🔒️(organization) the first user is not admin
The first user of a organization is probably not an admin.
This was implemented for first tests but for now it's more
a security issue than something helpful.

FIXES #775
2026-01-22 11:26:58 +01:00
Marie PUPO JEAMMET b8beb56135 🐛(admin) fix broken alias import
fix admin action to sync aliases of a given domain
2026-01-21 17:30:30 +00:00
mjeammet 37e5b5b346 🌐(i18n) update translated strings
Update translated files with new translations
2026-01-20 12:29:49 +00:00
Marie PUPO JEAMMET 31201fbd59 🔖(minor) release version 1.22.0
Update all version files and changelog for minor release.
2026-01-20 12:29:49 +00:00
elvoisin eb0683ffe0 (front) create, manage & delete aliases (#1013)
* (front) add aliases

add list view aliases + creation aliases

* (front) add delete alias

add modale to delete aliases

* 🐛(react-query) remove onMutateResult from mutation callbacks

remove onMutateResult from mutation callbacks
2026-01-19 17:04:57 +01:00
Marie PUPO JEAMMET 54219d25b8 (tests) update auth-related tests after bumping django-lasuite
update the expected number of queries in authentication-related tests
due to bumping django-lasuite to 0.0.22
2026-01-19 10:09:29 +00:00
renovate[bot] 89e7703d53 ⬆️(dependencies) update python dependencies
keep django < 6.0 for compatibility with django-celery-beat
See https://github.com/celery/django-celery-beat/issues/977
2026-01-19 10:09:29 +00:00
Marie PUPO JEAMMET 7d44e54913 (admin) manage aliases from admin
manage aliases from admin, including alias creation,
deletion and sync
2026-01-16 15:19:37 +00:00
Marie PUPO JEAMMET 01583ba94f 🐛(aliases) sort aliases by local part
sort aliases by local part to ease front-end interface
2026-01-16 15:19:37 +00:00
Marie PUPO JEAMMET 5feee53bdd 🔒️(security) upgrade python version to fix vulnerability
Vulnerability in jaraco.context caused security issue
in setuptools and python3. change python version to fix
see https://github.com/advisories/GHSA-58pv-8j8x-9vj2
2026-01-16 11:23:55 +00:00
Marie PUPO JEAMMET 9c62efc9f8 🐛(dimail) authorize alias and mailbox with same local part
a mailbox and alias can coexist despite having the same local part
2026-01-15 10:29:49 +00:00
Marie PUPO JEAMMET d2ef9e0beb 🐛(dimail) ignore oxadmin mailboxes when importing mailboxes
oxadmin mailbox are technical mailboxes used by dimail. It should
not be imported when importing mailboxes from dimail.
2026-01-15 10:29:49 +00:00
Marie PUPO JEAMMET bc1cbef168 (aliases) delete all aliases of a given local part
added a bulk delete method for aliases, when filtering on local part
this is convenient when in need to delete the local part and all its
destinations in a single call
2026-01-12 14:31:53 +00:00
Marie PUPO JEAMMET 8ab1b2e2ef 🔧(settings) separate dimail container from app-dev
separate dimail container from app-dev to avoid setting
dimail container everytime we run the test suite.
2026-01-12 14:31:53 +00:00
Marie PUPO JEAMMET f498e3b6d2 ♻️(lint) mark a few dimail methods as private
mark a few dimail methods as private, to calm linter
2026-01-12 14:31:53 +00:00
Marie PUPO JEAMMET e47573e22f (aliases) delete aliases
delete a single alias using its pk
2026-01-12 14:31:53 +00:00
Sabrina Demagny c7aab6f5b5 🔥(plugins) remove CommuneCreation plugin
Remove the French communes auto-provisioning plugin that handled
SIRET lookup, DNS setup, and MailDomain creation.
2026-01-09 09:38:01 +01:00
renovate[bot] 2144ad5da1 ⬆️(dependencies) update next to v15.4.10 [SECURITY] 2025-12-15 14:43:33 +00:00
renovate[bot] f00deeebe9 ⬆️(dependencies) update next to v15.4.9 [SECURITY] 2025-12-12 12:41:33 +00:00
83 changed files with 2969 additions and 1300 deletions
+1 -1
View File
@@ -25,7 +25,7 @@ jobs:
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: "3.11"
python-version: '3.13'
- name: Upgrade pip and setuptools
run: pip install --upgrade pip setuptools
- name: Install development dependencies
+2 -2
View File
@@ -224,7 +224,7 @@ jobs:
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
python-version: '3.13'
- name: Install development dependencies
run: pip install --user .[dev]
- name: Check code formatting with ruff
@@ -281,7 +281,7 @@ jobs:
- name: Install Python
uses: actions/setup-python@v5
with:
python-version: '3.11'
python-version: '3.13'
- name: Install development dependencies
run: pip install --user .[dev]
- name: Install gettext (required to compile messages)
+37 -1
View File
@@ -8,6 +8,39 @@ and this project adheres to
## [Unreleased]
### Added
- ✨(invitations) can delete domain invitations
## [1.22.2] - 2026-01-26
### Fixed
- 🐛(aliases) authorize special domain devnull in alias destinations #1029
## [1.22.1] - 2026-01-21
- 🔒️(organization) the first user is not admin #776
- 🐛(admin) fix broken alias import #1021
## [1.22.0] - 2026-01-19
### Added
- ✨(front) create, manage & delete aliases
- ✨(domains) alias sorting and admin
- ✨(aliases) delete all aliases in one call #1002
### Fixed
- 🔒️(security) upgrade python version to fix vulnerability #1010
- 🐛(dimail) ignore oxadmin when importing mailboxes from dimail #986
- ✨(aliases) fix deleting single aliases #1002
### Changed
- 🐛(dimail) allow mailboxes and aliases to have the same local part #986
### Removed
- 🔥(plugins) remove CommuneCreation plugin
## [1.21.0] - 2025-12-05
- ✨(aliases) import existing aliases from dimail
@@ -457,7 +490,10 @@ and this project adheres to
- ✨(domains) create and manage domains on admin + API
- ✨(domains) mailbox creation + link to email provisioning API
[unreleased]: https://github.com/suitenumerique/people/compare/v1.21.0...main
[unreleased]: https://github.com/suitenumerique/people/compare/v1.22.2...main
[1.22.2]: https://github.com/suitenumerique/people/releases/v1.22.2
[1.22.1]: https://github.com/suitenumerique/people/releases/v1.22.1
[1.22.0]: https://github.com/suitenumerique/people/releases/v1.22.0
[1.21.0]: https://github.com/suitenumerique/people/releases/v1.21.0
[1.20.0]: https://github.com/suitenumerique/people/releases/v1.20.0
[1.19.1]: https://github.com/suitenumerique/people/releases/v1.19.1
+2 -2
View File
@@ -1,10 +1,10 @@
# Django People
# ---- base image to inherit from ----
FROM python:3.12.6-alpine3.20 AS base
FROM python:3.13.11-alpine AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip setuptools
RUN python -m pip install --upgrade pip
# Upgrade system packages to install security updates
RUN apk update && \
+2 -1
View File
@@ -108,6 +108,7 @@ bootstrap: \
# -- Docker/compose
build: ## build the app-dev container
@$(COMPOSE) build app-dev
@$(COMPOSE) build dimail
.PHONY: build
down: ## stop and remove containers, networks, images, and volumes
@@ -123,7 +124,7 @@ run: ## start the wsgi (production) and servers with production Docker images
.PHONY: run
run-dev: ## start the servers in development mode (watch) Docker images
@$(COMPOSE) up --force-recreate --detach app-dev frontend-dev celery-dev celery-beat-dev nginx maildev
@$(COMPOSE) up --force-recreate --detach app-dev dimail frontend-dev celery-dev celery-beat-dev nginx maildev
.PHONY: run-dev
status: ## an alias for "docker compose ps"
-2
View File
@@ -46,8 +46,6 @@ services:
postgresql:
condition: service_healthy
restart: true
dimail:
condition: service_started
maildev:
condition: service_started
redis:
-1
View File
@@ -1,2 +1 @@
INSTALLED_PLUGINS=plugins.la_suite
DNS_PROVISIONING_TARGET_ZONE=test.collectivite.fr
@@ -17,8 +17,6 @@ from core.models import (
AccountService,
Contact,
Organization,
OrganizationAccess,
OrganizationRoleChoices,
)
logger = logging.getLogger(__name__)
@@ -131,15 +129,6 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
user = super().create_user(claims | {"organization": organization})
if organization_created:
# Warning: we may remove this behavior in the near future when we
# add a feature to claim the organization ownership.
OrganizationAccess.objects.create(
organization=organization,
user=user,
role=OrganizationRoleChoices.ADMIN,
)
# Initiate the user's profile
Contact.objects.create(
owner=user,
@@ -100,7 +100,7 @@ def test_authentication_getter_existing_user_change_fields(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
# One and only one additional update query when a field has changed
with django_assert_num_queries(2):
with django_assert_num_queries(4):
authenticated_user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -160,7 +160,8 @@ def test_authentication_getter_existing_user_via_email(
monkeypatch.setattr(OIDCAuthenticationBackend, "get_userinfo", get_userinfo_mocked)
with django_assert_num_queries(3): # user by email + user by sub + update sub
with django_assert_num_queries(5):
# 5 = user by email + user by sub + update sub + 2 from django-lasuite
user = klass.get_or_create_user(
access_token="test-token", id_token=None, payload=None
)
@@ -390,6 +391,8 @@ def test_authentication_getter_new_user_with_registration_id_new_organization(
assert user.organization.domain_list == expected_domain_list
assert user.organization.registration_id_list == expected_registration_id_list
assert models.OrganizationAccess.objects.filter(user=user).exists() is False
def test_authentication_getter_existing_user_via_email_update_organization(
django_assert_num_queries, monkeypatch
@@ -1,7 +1,6 @@
"""Tests for the authentication process of the resource server."""
import base64
import json
import pytest
import responses
@@ -84,8 +83,7 @@ def test_resource_server_authentication_class(client, settings):
settings.OIDC_OP_JWKS_ENDPOINT = "https://oidc.example.com/jwks"
settings.OIDC_OP_INTROSPECTION_ENDPOINT = "https://oidc.example.com/introspect"
responses.add(
responses.POST,
responses.post(
"https://oidc.example.com/introspect",
json={
"iss": "https://oidc.example.com",
@@ -181,23 +179,20 @@ def test_jwt_resource_server_authentication_class( # pylint: disable=unused-arg
# Mock the JWKS endpoint
public_numbers = private_key.public_key().public_numbers()
responses.add(
responses.GET,
responses.get(
settings.OIDC_OP_JWKS_ENDPOINT,
body=json.dumps(
{
"keys": [
{
"kty": settings.OIDC_RS_ENCRYPTION_KEY_TYPE,
"alg": settings.OIDC_RS_SIGNING_ALGO,
"use": "sig",
"kid": "1234567890",
"n": to_base64url_uint(public_numbers.n).decode("ascii"),
"e": to_base64url_uint(public_numbers.e).decode("ascii"),
}
]
}
),
json={
"keys": [
{
"kty": settings.OIDC_RS_ENCRYPTION_KEY_TYPE,
"alg": settings.OIDC_RS_SIGNING_ALGO,
"use": "sig",
"kid": "1234567890",
"n": to_base64url_uint(public_numbers.n).decode("ascii"),
"e": to_base64url_uint(public_numbers.e).decode("ascii"),
}
]
},
)
def encrypt_jwt(json_data):
@@ -225,8 +220,7 @@ def test_jwt_resource_server_authentication_class( # pylint: disable=unused-arg
],
)
responses.add(
responses.POST,
responses.post(
"https://oidc.example.com/introspect",
body=encrypt_jwt(
{
@@ -37,7 +37,7 @@ def test_matrix_webhook__search_user_unknown(caplog):
# Mock successful responses
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_empty()["message"]),
json=matrix.mock_search_empty()["message"],
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -62,7 +62,7 @@ def test_matrix_webhook__search_multiple_ids(caplog):
# Mock successful responses
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful_multiple(user)["message"]),
json=matrix.mock_search_successful_multiple(user)["message"],
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -92,7 +92,7 @@ def test_matrix_webhook__invite_user_to_room_forbidden(caplog):
)
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(user)["message"]),
json=matrix.mock_search_successful(user)["message"],
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post(
@@ -123,7 +123,7 @@ def test_matrix_webhook__invite_user_to_room_already_in_room(caplog):
)
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(user)["message"]),
json=matrix.mock_search_successful(user)["message"],
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post(
@@ -165,7 +165,7 @@ def test_matrix_webhook__invite_user_to_room_success(caplog):
)
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(user)["message"]),
json=matrix.mock_search_successful(user)["message"],
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post(
@@ -216,7 +216,7 @@ def test_matrix_webhook__override_secret_for_tchap():
)
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(user)["message"]),
json=matrix.mock_search_successful(user)["message"],
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post(
@@ -250,17 +250,17 @@ def test_matrix_webhook__kick_user_from_room_not_in_room(caplog):
# Mock successful responses
responses.post(
re.compile(r".*/join"),
body=str(matrix.mock_join_room_successful),
json=matrix.mock_join_room_successful,
status=status.HTTP_200_OK,
)
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(user)["message"]),
json=matrix.mock_search_successful(user)["message"],
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post(
re.compile(r".*/kick"),
body=str(matrix.mock_kick_user_not_in_room()["message"]),
json=matrix.mock_kick_user_not_in_room()["message"],
status=matrix.mock_kick_user_not_in_room()["status_code"],
)
webhooks_synchronizer.remove_user_from_group(team=webhook.team, user=user)
@@ -296,7 +296,7 @@ def test_matrix_webhook__kick_user_from_room_success(caplog):
)
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(user)["message"]),
json=matrix.mock_search_successful(user)["message"],
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post(
@@ -345,7 +345,7 @@ def test_matrix_webhook__kick_user_from_room_forbidden(caplog):
)
responses.post(
re.compile(r".*/search"),
body=json.dumps(matrix.mock_search_successful(user)["message"]),
json=matrix.mock_search_successful(user)["message"],
status=matrix.mock_search_successful(user)["status_code"],
)
responses.post(
+76 -53
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-22 08:25+0000\n"
"PO-Revision-Date: 2025-10-22 09:28\n"
"POT-Creation-Date: 2026-01-23 17:58+0000\n"
"PO-Revision-Date: 2026-01-26 11:02\n"
"Last-Translator: \n"
"Language-Team: English\n"
"Language: en_US\n"
@@ -45,24 +45,24 @@ msgstr ""
msgid "People core application"
msgstr ""
#: build/lib/core/authentication/backends.py:104
#: core/authentication/backends.py:104
#: build/lib/core/authentication/backends.py:102
#: core/authentication/backends.py:102
msgid "Claims contained no recognizable user identification"
msgstr ""
#: build/lib/core/authentication/backends.py:124
#: core/authentication/backends.py:124
#: build/lib/core/authentication/backends.py:122
#: core/authentication/backends.py:122
msgid "Claims contained no recognizable organization identification"
msgstr ""
#: build/lib/core/authentication/backends.py:181
#: build/lib/core/authentication/backends.py:183
#: core/authentication/backends.py:181 core/authentication/backends.py:183
#: build/lib/core/authentication/backends.py:170
#: build/lib/core/authentication/backends.py:172
#: core/authentication/backends.py:170 core/authentication/backends.py:172
msgid "Invalid authorization header."
msgstr ""
#: build/lib/core/authentication/backends.py:188
#: core/authentication/backends.py:188
#: build/lib/core/authentication/backends.py:177
#: core/authentication/backends.py:177
msgid "Invalid api key."
msgstr ""
@@ -149,8 +149,8 @@ msgstr ""
#: build/lib/core/models.py:250 build/lib/core/models.py:364
#: build/lib/core/models.py:510 build/lib/core/models.py:1121
#: build/lib/mailbox_manager/models.py:54 core/models.py:250 core/models.py:364
#: core/models.py:510 core/models.py:1121 mailbox_manager/models.py:54
#: build/lib/mailbox_manager/models.py:55 core/models.py:250 core/models.py:364
#: core/models.py:510 core/models.py:1121 mailbox_manager/models.py:55
msgid "name"
msgstr ""
@@ -361,90 +361,101 @@ msgstr ""
msgid "Account services"
msgstr ""
#: build/lib/mailbox_manager/admin.py:16 mailbox_manager/admin.py:16
#: build/lib/mailbox_manager/admin.py:14 mailbox_manager/admin.py:14
msgid "Import emails from dimail"
msgstr ""
#: build/lib/mailbox_manager/admin.py:34 mailbox_manager/admin.py:34
#: build/lib/mailbox_manager/admin.py:32 build/lib/mailbox_manager/admin.py:71
#: mailbox_manager/admin.py:32 mailbox_manager/admin.py:71
#, python-format
msgid "Synchronisation failed for %(domain)s with message: %(err)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:41 mailbox_manager/admin.py:41
#: build/lib/mailbox_manager/admin.py:39 mailbox_manager/admin.py:39
#, python-format
msgid "Synchronisation succeed for %(domain)s. Imported mailboxes: %(mailboxes)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:48 mailbox_manager/admin.py:48
#: build/lib/mailbox_manager/admin.py:46 build/lib/mailbox_manager/admin.py:91
#: mailbox_manager/admin.py:46 mailbox_manager/admin.py:91
#, python-format
msgid "Sync require enabled domains. Excluded domains: %(domains)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:53 mailbox_manager/admin.py:53
#: build/lib/mailbox_manager/admin.py:51 mailbox_manager/admin.py:51
msgid "Import aliases from dimail"
msgstr ""
#: build/lib/mailbox_manager/admin.py:78 mailbox_manager/admin.py:78
#, python-format
msgid "Synchronisation succeed for %(domain)s.Imported %(count_imported)s aliases: %(aliases)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:96 mailbox_manager/admin.py:96
msgid "Check and update status from dimail"
msgstr ""
#: build/lib/mailbox_manager/admin.py:70 mailbox_manager/admin.py:70
#: build/lib/mailbox_manager/admin.py:113 mailbox_manager/admin.py:113
#, python-format
msgid "- %(domain)s with message: %(err)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:83 mailbox_manager/admin.py:83
#: build/lib/mailbox_manager/admin.py:126 mailbox_manager/admin.py:126
msgid "Check domains done with success."
msgstr ""
#: build/lib/mailbox_manager/admin.py:84 mailbox_manager/admin.py:84
#: build/lib/mailbox_manager/admin.py:127 mailbox_manager/admin.py:127
#, python-format
msgid "Domains updated: %(domains)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:86 mailbox_manager/admin.py:86
#: build/lib/mailbox_manager/admin.py:129 mailbox_manager/admin.py:129
msgid "No domain updated."
msgstr ""
#: build/lib/mailbox_manager/admin.py:93 mailbox_manager/admin.py:93
#: build/lib/mailbox_manager/admin.py:136 mailbox_manager/admin.py:136
msgid "Check domain failed for:"
msgstr ""
#: build/lib/mailbox_manager/admin.py:101 mailbox_manager/admin.py:101
#: build/lib/mailbox_manager/admin.py:144 mailbox_manager/admin.py:144
#, python-format
msgid "Domains disabled are excluded from check: %(domains)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:106 mailbox_manager/admin.py:106
#: build/lib/mailbox_manager/admin.py:149 mailbox_manager/admin.py:149
msgid "Fetch domain expected config from dimail"
msgstr ""
#: build/lib/mailbox_manager/admin.py:120 mailbox_manager/admin.py:120
#: build/lib/mailbox_manager/admin.py:163 mailbox_manager/admin.py:163
#, python-format
msgid "Domain expected config fetched with success for %(domain)s."
msgstr ""
#: build/lib/mailbox_manager/admin.py:126 mailbox_manager/admin.py:126
#: build/lib/mailbox_manager/admin.py:169 mailbox_manager/admin.py:169
#, python-format
msgid "Failed to fetch domain expected config for %(domain)s."
msgstr ""
#: build/lib/mailbox_manager/admin.py:132 mailbox_manager/admin.py:132
#: build/lib/mailbox_manager/admin.py:175 mailbox_manager/admin.py:175
#, python-format
msgid "Domains disabled are excluded from fetch: %(domains)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:137 mailbox_manager/admin.py:137
#: build/lib/mailbox_manager/admin.py:180 mailbox_manager/admin.py:180
msgid "Send pending mailboxes to dimail"
msgstr ""
#: build/lib/mailbox_manager/admin.py:153 mailbox_manager/admin.py:153
#: build/lib/mailbox_manager/admin.py:196 mailbox_manager/admin.py:196
#, python-format
msgid "Failed to send the following mailboxes : %(mailboxes)s."
msgstr ""
#: build/lib/mailbox_manager/admin.py:159 mailbox_manager/admin.py:159
#: build/lib/mailbox_manager/admin.py:202 mailbox_manager/admin.py:202
#, python-format
msgid "Pending mailboxes successfully sent for %(domain)s."
msgstr ""
#: build/lib/mailbox_manager/admin.py:165 mailbox_manager/admin.py:165
#: build/lib/mailbox_manager/admin.py:208 mailbox_manager/admin.py:208
#, python-format
msgid "Domains disabled are excluded from : %(domains)s"
msgstr ""
@@ -476,90 +487,102 @@ msgstr ""
msgid "Action required"
msgstr ""
#: build/lib/mailbox_manager/models.py:33 mailbox_manager/models.py:33
#: build/lib/mailbox_manager/models.py:34 mailbox_manager/models.py:34
msgid "[La Suite] Your domain is ready"
msgstr ""
#: build/lib/mailbox_manager/models.py:38 mailbox_manager/models.py:38
#: build/lib/mailbox_manager/models.py:39 mailbox_manager/models.py:39
msgid "[La Suite] Your domain requires action"
msgstr ""
#: build/lib/mailbox_manager/models.py:43 mailbox_manager/models.py:43
#: build/lib/mailbox_manager/models.py:44 mailbox_manager/models.py:44
msgid "[La Suite] Your domain has failed"
msgstr ""
#: build/lib/mailbox_manager/models.py:69 mailbox_manager/models.py:69
#: build/lib/mailbox_manager/models.py:70 mailbox_manager/models.py:70
msgid "support email"
msgstr ""
#: build/lib/mailbox_manager/models.py:73 mailbox_manager/models.py:73
#: build/lib/mailbox_manager/models.py:74 mailbox_manager/models.py:74
msgid "last check details"
msgstr ""
#: build/lib/mailbox_manager/models.py:74 mailbox_manager/models.py:74
#: build/lib/mailbox_manager/models.py:75 mailbox_manager/models.py:75
msgid "A JSON object containing the last health check details"
msgstr ""
#: build/lib/mailbox_manager/models.py:79 mailbox_manager/models.py:79
#: build/lib/mailbox_manager/models.py:80 mailbox_manager/models.py:80
msgid "expected config"
msgstr ""
#: build/lib/mailbox_manager/models.py:80 mailbox_manager/models.py:80
#: build/lib/mailbox_manager/models.py:81 mailbox_manager/models.py:81
msgid "A JSON object containing the expected config"
msgstr ""
#: build/lib/mailbox_manager/models.py:85 mailbox_manager/models.py:85
#: build/lib/mailbox_manager/models.py:86 mailbox_manager/models.py:86
msgid "Mail domain"
msgstr ""
#: build/lib/mailbox_manager/models.py:86 mailbox_manager/models.py:86
#: build/lib/mailbox_manager/models.py:87 mailbox_manager/models.py:87
msgid "Mail domains"
msgstr ""
#: build/lib/mailbox_manager/models.py:200 mailbox_manager/models.py:200
#: build/lib/mailbox_manager/models.py:203 mailbox_manager/models.py:203
msgid "User/mail domain relation"
msgstr ""
#: build/lib/mailbox_manager/models.py:201 mailbox_manager/models.py:201
#: build/lib/mailbox_manager/models.py:204 mailbox_manager/models.py:204
msgid "User/mail domain relations"
msgstr ""
#: build/lib/mailbox_manager/models.py:274 mailbox_manager/models.py:274
#: build/lib/mailbox_manager/models.py:277 mailbox_manager/models.py:277
msgid "local_part"
msgstr ""
#: build/lib/mailbox_manager/models.py:288 mailbox_manager/models.py:288
#: build/lib/mailbox_manager/models.py:291 mailbox_manager/models.py:291
msgid "secondary email address"
msgstr ""
#: build/lib/mailbox_manager/models.py:299 mailbox_manager/models.py:299
#: build/lib/mailbox_manager/models.py:302 mailbox_manager/models.py:302
msgid "email"
msgstr ""
#: build/lib/mailbox_manager/models.py:305 mailbox_manager/models.py:305
#: build/lib/mailbox_manager/models.py:308 mailbox_manager/models.py:308
msgid "Mailbox"
msgstr ""
#: build/lib/mailbox_manager/models.py:306 mailbox_manager/models.py:306
#: build/lib/mailbox_manager/models.py:309 mailbox_manager/models.py:309
msgid "Mailboxes"
msgstr ""
#: build/lib/mailbox_manager/models.py:348 mailbox_manager/models.py:348
#: build/lib/mailbox_manager/models.py:351 mailbox_manager/models.py:351
msgid "You can't create or update a mailbox for a disabled domain."
msgstr ""
#: build/lib/mailbox_manager/models.py:405 mailbox_manager/models.py:405
#: build/lib/mailbox_manager/models.py:408 mailbox_manager/models.py:408
msgid "Mail domain invitation"
msgstr ""
#: build/lib/mailbox_manager/models.py:406 mailbox_manager/models.py:406
#: build/lib/mailbox_manager/models.py:409 mailbox_manager/models.py:409
msgid "Mail domain invitations"
msgstr ""
#: build/lib/mailbox_manager/models.py:418 mailbox_manager/models.py:418
#: build/lib/mailbox_manager/models.py:421 mailbox_manager/models.py:421
msgid "[La Suite] You have been invited to join La Régie"
msgstr ""
#: build/lib/mailbox_manager/models.py:465 mailbox_manager/models.py:465
msgid "destination address"
msgstr ""
#: build/lib/mailbox_manager/models.py:483 mailbox_manager/models.py:483
msgid "Alias"
msgstr ""
#: build/lib/mailbox_manager/models.py:484 mailbox_manager/models.py:484
msgid "Aliases"
msgstr ""
#: build/lib/mailbox_manager/utils/dimail.py:296
#: mailbox_manager/utils/dimail.py:296
msgid "Your new mailbox information"
+76 -53
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-10-22 08:25+0000\n"
"PO-Revision-Date: 2025-10-22 09:28\n"
"POT-Creation-Date: 2026-01-23 17:58+0000\n"
"PO-Revision-Date: 2026-01-26 11:02\n"
"Last-Translator: \n"
"Language-Team: French\n"
"Language: fr_FR\n"
@@ -45,24 +45,24 @@ msgstr "Les plugins de post-création ont été exécutés pour les organisation
msgid "People core application"
msgstr "Application cœur de People"
#: build/lib/core/authentication/backends.py:104
#: core/authentication/backends.py:104
#: build/lib/core/authentication/backends.py:102
#: core/authentication/backends.py:102
msgid "Claims contained no recognizable user identification"
msgstr "Les claims ne contiennent aucune identification reconnaissable pour l'organisation"
#: build/lib/core/authentication/backends.py:124
#: core/authentication/backends.py:124
#: build/lib/core/authentication/backends.py:122
#: core/authentication/backends.py:122
msgid "Claims contained no recognizable organization identification"
msgstr "Les claims ne contiennent aucune identification reconnaissable pour l'organisation"
#: build/lib/core/authentication/backends.py:181
#: build/lib/core/authentication/backends.py:183
#: core/authentication/backends.py:181 core/authentication/backends.py:183
#: build/lib/core/authentication/backends.py:170
#: build/lib/core/authentication/backends.py:172
#: core/authentication/backends.py:170 core/authentication/backends.py:172
msgid "Invalid authorization header."
msgstr "En-tête d'autorisation invalide."
#: build/lib/core/authentication/backends.py:188
#: core/authentication/backends.py:188
#: build/lib/core/authentication/backends.py:177
#: core/authentication/backends.py:177
msgid "Invalid api key."
msgstr "Clé API invalide."
@@ -149,8 +149,8 @@ msgstr "contacts"
#: build/lib/core/models.py:250 build/lib/core/models.py:364
#: build/lib/core/models.py:510 build/lib/core/models.py:1121
#: build/lib/mailbox_manager/models.py:54 core/models.py:250 core/models.py:364
#: core/models.py:510 core/models.py:1121 mailbox_manager/models.py:54
#: build/lib/mailbox_manager/models.py:55 core/models.py:250 core/models.py:364
#: core/models.py:510 core/models.py:1121 mailbox_manager/models.py:55
msgid "name"
msgstr "nom"
@@ -361,90 +361,101 @@ msgstr "Compte de service"
msgid "Account services"
msgstr "Comptes de service"
#: build/lib/mailbox_manager/admin.py:16 mailbox_manager/admin.py:16
#: build/lib/mailbox_manager/admin.py:14 mailbox_manager/admin.py:14
msgid "Import emails from dimail"
msgstr "Importer les emails depuis dimail"
#: build/lib/mailbox_manager/admin.py:34 mailbox_manager/admin.py:34
#: build/lib/mailbox_manager/admin.py:32 build/lib/mailbox_manager/admin.py:71
#: mailbox_manager/admin.py:32 mailbox_manager/admin.py:71
#, python-format
msgid "Synchronisation failed for %(domain)s with message: %(err)s"
msgstr "La synchronisation a échoué pour %(domain)s avec le message : %(err)s"
#: build/lib/mailbox_manager/admin.py:41 mailbox_manager/admin.py:41
#: build/lib/mailbox_manager/admin.py:39 mailbox_manager/admin.py:39
#, python-format
msgid "Synchronisation succeed for %(domain)s. Imported mailboxes: %(mailboxes)s"
msgstr "La synchronisation a réussi pour %(domain)s. Importation des boîtes mails : %(mailboxes)s"
#: build/lib/mailbox_manager/admin.py:48 mailbox_manager/admin.py:48
#: build/lib/mailbox_manager/admin.py:46 build/lib/mailbox_manager/admin.py:91
#: mailbox_manager/admin.py:46 mailbox_manager/admin.py:91
#, python-format
msgid "Sync require enabled domains. Excluded domains: %(domains)s"
msgstr "La synchro nécessite des domaines activés. Les domaines exclus sont : %(domains)s"
#: build/lib/mailbox_manager/admin.py:53 mailbox_manager/admin.py:53
#: build/lib/mailbox_manager/admin.py:51 mailbox_manager/admin.py:51
msgid "Import aliases from dimail"
msgstr ""
#: build/lib/mailbox_manager/admin.py:78 mailbox_manager/admin.py:78
#, python-format
msgid "Synchronisation succeed for %(domain)s.Imported %(count_imported)s aliases: %(aliases)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:96 mailbox_manager/admin.py:96
msgid "Check and update status from dimail"
msgstr "Vérifier et mettre à jour le statut à partir de dimail"
#: build/lib/mailbox_manager/admin.py:70 mailbox_manager/admin.py:70
#: build/lib/mailbox_manager/admin.py:113 mailbox_manager/admin.py:113
#, python-format
msgid "- %(domain)s with message: %(err)s"
msgstr "- %(domain)s avec le message : %(err)s"
#: build/lib/mailbox_manager/admin.py:83 mailbox_manager/admin.py:83
#: build/lib/mailbox_manager/admin.py:126 mailbox_manager/admin.py:126
msgid "Check domains done with success."
msgstr "Vérification des domaines effectuée avec succès."
#: build/lib/mailbox_manager/admin.py:84 mailbox_manager/admin.py:84
#: build/lib/mailbox_manager/admin.py:127 mailbox_manager/admin.py:127
#, python-format
msgid "Domains updated: %(domains)s"
msgstr "Domaines mis à jour : %(domains)s"
#: build/lib/mailbox_manager/admin.py:86 mailbox_manager/admin.py:86
#: build/lib/mailbox_manager/admin.py:129 mailbox_manager/admin.py:129
msgid "No domain updated."
msgstr "Aucun domaine mis à jour."
#: build/lib/mailbox_manager/admin.py:93 mailbox_manager/admin.py:93
#: build/lib/mailbox_manager/admin.py:136 mailbox_manager/admin.py:136
msgid "Check domain failed for:"
msgstr "La vérification du domaine a échoué pour :"
#: build/lib/mailbox_manager/admin.py:101 mailbox_manager/admin.py:101
#: build/lib/mailbox_manager/admin.py:144 mailbox_manager/admin.py:144
#, python-format
msgid "Domains disabled are excluded from check: %(domains)s"
msgstr "Les domaines désactivés sont exclus de la vérification : %(domains)s"
#: build/lib/mailbox_manager/admin.py:106 mailbox_manager/admin.py:106
#: build/lib/mailbox_manager/admin.py:149 mailbox_manager/admin.py:149
msgid "Fetch domain expected config from dimail"
msgstr "Récupérer la configuration attendue du domaine depuis dimail"
#: build/lib/mailbox_manager/admin.py:120 mailbox_manager/admin.py:120
#: build/lib/mailbox_manager/admin.py:163 mailbox_manager/admin.py:163
#, python-format
msgid "Domain expected config fetched with success for %(domain)s."
msgstr "La configuration du domaine attendue a été récupérée avec succès pour %(domain)s."
#: build/lib/mailbox_manager/admin.py:126 mailbox_manager/admin.py:126
#: build/lib/mailbox_manager/admin.py:169 mailbox_manager/admin.py:169
#, python-format
msgid "Failed to fetch domain expected config for %(domain)s."
msgstr "Impossible de récupérer la configuration attendue pour %(domain)s."
#: build/lib/mailbox_manager/admin.py:132 mailbox_manager/admin.py:132
#: build/lib/mailbox_manager/admin.py:175 mailbox_manager/admin.py:175
#, python-format
msgid "Domains disabled are excluded from fetch: %(domains)s"
msgstr "Les domaines désactivés sont exclus de la récupération : %(domains)s"
#: build/lib/mailbox_manager/admin.py:137 mailbox_manager/admin.py:137
#: build/lib/mailbox_manager/admin.py:180 mailbox_manager/admin.py:180
msgid "Send pending mailboxes to dimail"
msgstr "Envoyer les adresses mail en attente à dimail"
#: build/lib/mailbox_manager/admin.py:153 mailbox_manager/admin.py:153
#: build/lib/mailbox_manager/admin.py:196 mailbox_manager/admin.py:196
#, python-format
msgid "Failed to send the following mailboxes : %(mailboxes)s."
msgstr "Échec de l'envoi des adresses mail suivantes : %(mailboxes)s."
#: build/lib/mailbox_manager/admin.py:159 mailbox_manager/admin.py:159
#: build/lib/mailbox_manager/admin.py:202 mailbox_manager/admin.py:202
#, python-format
msgid "Pending mailboxes successfully sent for %(domain)s."
msgstr "Succès de l'envoi des adresses en attente pour le domaine %(domain)s."
#: build/lib/mailbox_manager/admin.py:165 mailbox_manager/admin.py:165
#: build/lib/mailbox_manager/admin.py:208 mailbox_manager/admin.py:208
#, python-format
msgid "Domains disabled are excluded from : %(domains)s"
msgstr "Les domaines désactivés ont été exclu : %(domains)s"
@@ -476,90 +487,102 @@ msgstr "Désactivé"
msgid "Action required"
msgstr "Action requise"
#: build/lib/mailbox_manager/models.py:33 mailbox_manager/models.py:33
#: build/lib/mailbox_manager/models.py:34 mailbox_manager/models.py:34
msgid "[La Suite] Your domain is ready"
msgstr "[La Suite] Votre domaine est prêt"
#: build/lib/mailbox_manager/models.py:38 mailbox_manager/models.py:38
#: build/lib/mailbox_manager/models.py:39 mailbox_manager/models.py:39
msgid "[La Suite] Your domain requires action"
msgstr "[La Suite] Des actions sont requises sur votre domaine"
#: build/lib/mailbox_manager/models.py:43 mailbox_manager/models.py:43
#: build/lib/mailbox_manager/models.py:44 mailbox_manager/models.py:44
msgid "[La Suite] Your domain has failed"
msgstr "[La Suite] Votre domaine est en erreur"
#: build/lib/mailbox_manager/models.py:69 mailbox_manager/models.py:69
#: build/lib/mailbox_manager/models.py:70 mailbox_manager/models.py:70
msgid "support email"
msgstr "adresse email du support"
#: build/lib/mailbox_manager/models.py:73 mailbox_manager/models.py:73
#: build/lib/mailbox_manager/models.py:74 mailbox_manager/models.py:74
msgid "last check details"
msgstr "détails de la dernière vérification"
#: build/lib/mailbox_manager/models.py:74 mailbox_manager/models.py:74
#: build/lib/mailbox_manager/models.py:75 mailbox_manager/models.py:75
msgid "A JSON object containing the last health check details"
msgstr "Un objet JSON contenant les derniers détails du bilan de santé"
#: build/lib/mailbox_manager/models.py:79 mailbox_manager/models.py:79
#: build/lib/mailbox_manager/models.py:80 mailbox_manager/models.py:80
msgid "expected config"
msgstr "configuration attendue"
#: build/lib/mailbox_manager/models.py:80 mailbox_manager/models.py:80
#: build/lib/mailbox_manager/models.py:81 mailbox_manager/models.py:81
msgid "A JSON object containing the expected config"
msgstr "Un objet JSON contenant la configuration attendue"
#: build/lib/mailbox_manager/models.py:85 mailbox_manager/models.py:85
#: build/lib/mailbox_manager/models.py:86 mailbox_manager/models.py:86
msgid "Mail domain"
msgstr "Domaine de messagerie"
#: build/lib/mailbox_manager/models.py:86 mailbox_manager/models.py:86
#: build/lib/mailbox_manager/models.py:87 mailbox_manager/models.py:87
msgid "Mail domains"
msgstr "Domaines de messagerie"
#: build/lib/mailbox_manager/models.py:200 mailbox_manager/models.py:200
#: build/lib/mailbox_manager/models.py:203 mailbox_manager/models.py:203
msgid "User/mail domain relation"
msgstr "Relation utilisateur/domaine de messagerie"
#: build/lib/mailbox_manager/models.py:201 mailbox_manager/models.py:201
#: build/lib/mailbox_manager/models.py:204 mailbox_manager/models.py:204
msgid "User/mail domain relations"
msgstr "Relations utilisateur/domaine de messagerie"
#: build/lib/mailbox_manager/models.py:274 mailbox_manager/models.py:274
#: build/lib/mailbox_manager/models.py:277 mailbox_manager/models.py:277
msgid "local_part"
msgstr "local_part"
#: build/lib/mailbox_manager/models.py:288 mailbox_manager/models.py:288
#: build/lib/mailbox_manager/models.py:291 mailbox_manager/models.py:291
msgid "secondary email address"
msgstr "adresse email secondaire"
#: build/lib/mailbox_manager/models.py:299 mailbox_manager/models.py:299
#: build/lib/mailbox_manager/models.py:302 mailbox_manager/models.py:302
msgid "email"
msgstr "email"
#: build/lib/mailbox_manager/models.py:305 mailbox_manager/models.py:305
#: build/lib/mailbox_manager/models.py:308 mailbox_manager/models.py:308
msgid "Mailbox"
msgstr "Boîte mail"
#: build/lib/mailbox_manager/models.py:306 mailbox_manager/models.py:306
#: build/lib/mailbox_manager/models.py:309 mailbox_manager/models.py:309
msgid "Mailboxes"
msgstr "Boîtes mail"
#: build/lib/mailbox_manager/models.py:348 mailbox_manager/models.py:348
#: build/lib/mailbox_manager/models.py:351 mailbox_manager/models.py:351
msgid "You can't create or update a mailbox for a disabled domain."
msgstr "Vous ne pouvez pas créer ou mettre à jour une boîte mail pour un domaine désactivé."
#: build/lib/mailbox_manager/models.py:405 mailbox_manager/models.py:405
#: build/lib/mailbox_manager/models.py:408 mailbox_manager/models.py:408
msgid "Mail domain invitation"
msgstr "Invitation au domaine de messagerie"
#: build/lib/mailbox_manager/models.py:406 mailbox_manager/models.py:406
#: build/lib/mailbox_manager/models.py:409 mailbox_manager/models.py:409
msgid "Mail domain invitations"
msgstr "Invitations au domaine de messagerie"
#: build/lib/mailbox_manager/models.py:418 mailbox_manager/models.py:418
#: build/lib/mailbox_manager/models.py:421 mailbox_manager/models.py:421
msgid "[La Suite] You have been invited to join La Régie"
msgstr "[La Suite] Vous avez été invité(e) à rejoindre la Régie"
#: build/lib/mailbox_manager/models.py:465 mailbox_manager/models.py:465
msgid "destination address"
msgstr "adresse de destination"
#: build/lib/mailbox_manager/models.py:483 mailbox_manager/models.py:483
msgid "Alias"
msgstr "Alias"
#: build/lib/mailbox_manager/models.py:484 mailbox_manager/models.py:484
msgid "Aliases"
msgstr "Alias"
#: build/lib/mailbox_manager/utils/dimail.py:296
#: mailbox_manager/utils/dimail.py:296
msgid "Your new mailbox information"
+16 -4
View File
@@ -75,15 +75,16 @@ def sync_aliases_from_dimail(modeladmin, request, queryset): # pylint: disable=
messages.success(
request,
_(
"Synchronisation succeed for %(domain)s. %(imported_aliases)\
imported aliases: %(mailboxes)s"
"Synchronisation succeed for %(domain)s.\
Imported %(count_imported)s aliases: %(aliases)s"
)
% {
"domain": domain.name,
"number_imported": len(imported_aliases),
"mailboxes": ", ".join(imported_aliases),
"count_imported": len(imported_aliases),
"aliases": ", ".join(imported_aliases),
},
)
if excluded_domains:
messages.warning(
request,
@@ -235,6 +236,7 @@ class MailDomainAdmin(admin.ModelAdmin):
inlines = (UserMailDomainAccessInline,)
actions = (
sync_mailboxes_from_dimail,
sync_aliases_from_dimail,
fetch_domain_status_from_dimail,
fetch_domain_expected_config_from_dimail,
send_pending_mailboxes,
@@ -308,3 +310,13 @@ class MailDomainInvitationAdmin(admin.ModelAdmin):
def is_expired(self, obj):
"""Return the expiration date of the invitation."""
return obj.is_expired
@admin.register(models.Alias)
class AliasAdmin(admin.ModelAdmin):
"""Admin for alias model."""
list_display = ("local_part", "domain", "destination", "updated_at")
list_filter = ("domain",)
search_fields = ("local_part", "domain__name", "destination")
readonly_fields = ["updated_at"]
@@ -4,6 +4,7 @@ from logging import getLogger
from django.contrib.auth.hashers import make_password
from django.core import exceptions as django_exceptions
from django.shortcuts import get_object_or_404
from requests.exceptions import HTTPError
from rest_framework import exceptions, serializers
@@ -78,17 +79,6 @@ class MailboxSerializer(serializers.ModelSerializer):
return mailbox
def validate_local_part(self, value):
"""Validate this local part does not match a mailbox."""
if models.Alias.objects.filter(
local_part=value, domain__slug=self.context["domain_slug"]
):
raise exceptions.ValidationError(
f'Local part "{value}" already used by an alias.'
)
return value
class MailboxUpdateSerializer(MailboxSerializer):
"""A more restrictive serializer when updating mailboxes"""
@@ -306,7 +296,9 @@ class MailDomainInvitationSerializer(serializers.ModelSerializer):
class AliasSerializer(serializers.ModelSerializer):
"""Serialize mailbox."""
"""Serialize aliases."""
domain = MailDomainSerializer(default="")
class Meta:
model = models.Alias
@@ -314,8 +306,13 @@ class AliasSerializer(serializers.ModelSerializer):
"id",
"local_part",
"destination",
"domain",
]
read_only_fields = ["id"]
read_only_fields = ["id", "domain"]
def validate_domain(self, value): # pylint: disable=unused-argument
"""Forcefully set domain field to url domain."""
return get_object_or_404(models.MailDomain, slug=self.context["domain_slug"])
def create(self, validated_data):
"""
@@ -330,14 +327,3 @@ class AliasSerializer(serializers.ModelSerializer):
return super().create(validated_data)
return None
def validate_local_part(self, value):
"""Validate this local part does not match a mailbox."""
if models.Mailbox.objects.filter(
local_part=value, domain__slug=self.context["domain_slug"]
).exists():
raise exceptions.ValidationError(
f'Local part "{value}" already used by a mailbox.'
)
return value
@@ -1,6 +1,7 @@
"""API endpoints"""
from django.db.models import Q, Subquery
from django.http import Http404
from rest_framework import exceptions, filters, mixins, status, viewsets
from rest_framework.decorators import action
@@ -347,6 +348,7 @@ class MailDomainInvitationViewset(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""API ViewSet for user invitations to domain management.
@@ -364,6 +366,9 @@ class MailDomainInvitationViewset(
PUT / PATCH : Not permitted. Instead of updating your invitation,
delete and create a new one.
DELETE /api/<version>/mail-domains/<domain_slug>/invitations/:<invitation_id>/
Delete targeted invitation
"""
lookup_field = "id"
@@ -422,16 +427,20 @@ class AliasViewSet(
- destination: str
Return a newly created alias
DELETE /api/<version>/mail-domains/<domain_slug>/accesses/<alias-local-part>/
DELETE /api/<version>/mail-domains/<domain_slug>/aliases/<alias_pk>/
Delete targeted alias
DELETE /api/<version>/mail-domains/<domain_slug>/aliases/?local_part=<local_part>/
Delete all aliases of targeted local_part
"""
lookup_field = "local_part"
lookup_field = "pk"
permission_classes = [permissions.DomainResourcePermission]
serializer_class = serializers.AliasSerializer
queryset = (
models.Alias.objects.all().select_related("domain").order_by("-created_at")
)
queryset = models.Alias.objects.all().select_related("domain")
filter_backends = [filters.OrderingFilter]
ordering_fields = ["local_part"]
ordering = ["local_part"]
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
@@ -459,39 +468,43 @@ class AliasViewSet(
return queryset
def get_permissions(self):
"""Add a specific permission for domain viewers to delete their aliases."""
if self.action in ["destroy"]:
permission_classes = [
permissions.DomainResourcePermission
| permissions.IsAliasDestinationPermission,
]
else:
return super().get_permissions()
return [permission() for permission in permission_classes]
def perform_create(self, serializer):
"""Create new mailbox."""
domain_slug = self.kwargs.get("domain_slug", "")
if domain_slug:
serializer.validated_data["domain"] = models.MailDomain.objects.get(
slug=domain_slug
)
super().perform_create(serializer)
def destroy(self, request, *args, **kwargs):
"""Override destroy method to send a delete request to dimail
and return clear message if domain out of sync."""
"""
Override destroy method to delete specific alias and send request to dimail.
"""
instance = self.get_object()
self.perform_destroy(instance)
client = DimailAPIClient()
dimail_response = client.delete_alias(instance)
if dimail_response.status_code == status.HTTP_404_NOT_FOUND:
return Response(
"Alias already deleted. Domain out of sync, please contact our support.",
"Domain out of sync with mailbox provider, please contact our support.",
status=status.HTTP_200_OK,
)
return Response(status=status.HTTP_204_NO_CONTENT)
@action(methods=["DELETE"], detail=False)
def delete(self, request, *args, **kwargs):
"""Bulk delete aliases. Filtering is required and accepted filter is local_part."""
if "local_part" not in self.request.query_params:
return Response(status=status.HTTP_400_BAD_REQUEST)
local_part = self.request.query_params["local_part"]
queryset = self.get_queryset().filter(
local_part=local_part
) # Manually call get_queryset to filter by domain and role
if not queryset:
raise Http404("No Alias matches the given query.")
# view is bounded to a domain, fetch is from the queryset to spare a dedicated DB request"
domain_name = queryset[0].domain.name
queryset.delete()
client = DimailAPIClient()
client.delete_multiple_alias(local_part, domain_name)
return Response(status=status.HTTP_204_NO_CONTENT)
+1 -15
View File
@@ -26,7 +26,7 @@ class DomainPermission(IsAuthenticated):
slug=view.kwargs.get("domain_slug", ""),
accesses__user=request.user,
)
# domain = models.MailDomain.objects.get(slug=view.kwargs.get("domain_slug", ""))
abilities = domain.get_abilities(request.user)
if request.method.lower() == "delete":
return abilities.get("manage_accesses", False)
@@ -55,17 +55,3 @@ class IsMailboxOwnerPermission(permissions.BasePermission):
def has_object_permission(self, request, view, obj):
"""If the user is trying to update their own mailbox."""
return obj.get_email() == request.user.email
class IsAliasDestinationPermission(IsAuthenticated):
"""Can delete an alias if the alias points to their own email address."""
def has_permission(self, request, view):
"""This permission is specifically about updates"""
domain = models.MailDomain.objects.get(slug=view.kwargs.get("domain_slug", ""))
abilities = domain.get_abilities(request.user)
return abilities["get"]
def has_object_permission(self, request, view, obj):
"""If the user is trying to update their own mailbox."""
return obj.destination == request.user.email
@@ -19,7 +19,7 @@ class Migration(migrations.Migration):
('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')),
('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')),
('local_part', models.CharField(max_length=100)),
('destination', models.EmailField(max_length=254, verbose_name='destination address')),
('destination', models.CharField(max_length=254, validators=[django.core.validators.EmailValidator(allowlist=['localhost', 'devnull'])], verbose_name='destination address')),
('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='aliases', to='mailbox_manager.maildomain')),
],
options={
@@ -0,0 +1,21 @@
# Generated by Django 5.2.9 on 2025-12-17 17:01
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0028_alias'),
]
operations = [
migrations.AlterUniqueTogether(
name='alias',
unique_together=set(),
),
migrations.AddConstraint(
model_name='alias',
constraint=models.UniqueConstraint(fields=('domain', 'local_part', 'destination'), name='no_duplicate'),
),
]
+17 -2
View File
@@ -9,6 +9,7 @@ from django.conf import settings
from django.contrib.auth.base_user import AbstractBaseUser
from django.contrib.sites.models import Site
from django.core import exceptions, mail, validators
from django.core.validators import EmailValidator
from django.db import models
from django.db.models.functions import Lower
from django.template.loader import render_to_string
@@ -89,6 +90,8 @@ class MailDomain(BaseModel):
def __str__(self):
return self.name
objects = models.Manager()
def save(self, *args, **kwargs):
"""Override save function to compute the slug."""
self.slug = self.get_slug()
@@ -458,7 +461,15 @@ class Alias(BaseModel):
"""Model for aliases."""
local_part = models.CharField(max_length=100, blank=False)
destination = models.EmailField(_("destination address"), null=False, blank=False)
destination = models.CharField(
_("destination address"),
max_length=254,
null=False,
blank=False,
validators=[
EmailValidator(allowlist=["localhost", "devnull"]),
],
)
domain = models.ForeignKey(
MailDomain,
on_delete=models.CASCADE,
@@ -471,8 +482,12 @@ class Alias(BaseModel):
db_table = "people_aliases"
verbose_name = _("Alias")
verbose_name_plural = _("Aliases")
unique_together = ("local_part", "destination")
ordering = ["-created_at"]
constraints = [
models.UniqueConstraint(
fields=["domain", "local_part", "destination"], name="no_duplicate"
)
]
def __str__(self):
return f"{self.local_part} to {self.destination}"
@@ -0,0 +1,129 @@
"""
Tests for aliases API endpoint in People's app mailbox_manager.
Focus on "bulk delete" action.
"""
# pylint: disable=W0613
import re
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, models
pytestmark = pytest.mark.django_db
def test_api_aliases_bulk_delete__anonymous_get_401():
"""Anonymous user should not be able to bulk delete."""
mail_domain = factories.MailDomainFactory()
alias_, _, _ = factories.AliasFactory.create_batch(3, domain=mail_domain)
client = APIClient()
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/?local_part={alias_.local_part}",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert models.Alias.objects.count() == 3
def test_api_aliases_bulk_delete__no_access_get_404():
"""User with no access to domain should not be able to bulk delete."""
mail_domain = factories.MailDomainFactory()
alias_, _, _ = factories.AliasFactory.create_batch(3, domain=mail_domain)
client = APIClient()
client.force_login(core_factories.UserFactory())
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/?local_part={alias_.local_part}",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert models.Alias.objects.count() == 3
def test_api_aliases_bulk_delete__viewer_get_403():
"""Viewer user should not be able to bulk delete."""
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.VIEWER)
alias_, _, _ = factories.AliasFactory.create_batch(3, domain=access.domain)
client = APIClient()
client.force_login(access.user)
response = client.delete(
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/?local_part={alias_.local_part}",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.Alias.objects.count() == 3
@responses.activate
def test_api_aliases_bulk_delete__administrators_allowed_all_destination(
dimail_token_ok,
):
"""
Administrators of a domain should be allowed to bulk delete all aliases
of a given local_part.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
)
alias_ = factories.AliasFactory(domain=mail_domain)
factories.AliasFactory.create_batch(
2, domain=mail_domain, local_part=alias_.local_part
)
# additional aliases that shouldn't be affected
factories.AliasFactory.create_batch(
2, domain=mail_domain, destination=alias_.destination
)
factories.AliasFactory(
local_part=alias_.local_part,
destination=alias_.destination,
)
# Mock dimail response
responses.delete(
re.compile(r".*/aliases/"),
status=status.HTTP_204_NO_CONTENT,
content_type="application/json",
)
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/?local_part={alias_.local_part}",
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert models.Alias.objects.count() == 3
assert not models.Alias.objects.filter(
domain=mail_domain, local_part=alias_.local_part
).exists()
def test_api_aliases_bulk_delete__no_local_part_bad_request():
"""Filtering by local part is mandatory when bulk deleting aliases."""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
)
alias_ = factories.AliasFactory(domain=mail_domain)
factories.AliasFactory.create_batch(
2, domain=mail_domain, local_part=alias_.local_part
)
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert models.Alias.objects.count() == 3
@@ -4,7 +4,6 @@ Focus on "create" action.
"""
# pylint: disable=W0613
import json
import re
import pytest
@@ -60,8 +59,9 @@ def test_api_aliases_create__viewer_forbidden():
assert not models.Alias.objects.exists()
@responses.activate
def test_api_aliases_create__duplicate_forbidden():
"""Cannot create alias if same local part + destination."""
"""Cannot create alias if existing alias same domain + local part + destination."""
access = factories.MailDomainAccessFactory(
role="owner", domain=factories.MailDomainEnabledFactory()
)
@@ -80,26 +80,6 @@ def test_api_aliases_create__duplicate_forbidden():
assert models.Alias.objects.filter(domain=access.domain).count() == 1
def test_api_aliases_create__existing_mailbox_bad_request():
"""Cannot create alias if local_part is already used by a mailbox."""
access = factories.MailDomainAccessFactory(
role="owner", domain=factories.MailDomainEnabledFactory()
)
mailbox = factories.MailboxFactory(domain=access.domain)
client = APIClient()
client.force_login(access.user)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/",
{"local_part": mailbox.local_part, "destination": "someone@outsidedomain.com"},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json() == {
"local_part": [f'Local part "{mailbox.local_part}" already used by a mailbox.']
}
assert not models.Alias.objects.exists()
@responses.activate
def test_api_aliases_create__async_alias_bad_request(dimail_token_ok):
"""
@@ -113,10 +93,9 @@ def test_api_aliases_create__async_alias_bad_request(dimail_token_ok):
client = APIClient()
client.force_login(access.user)
# Mock dimail response
responses.add(
responses.POST,
responses.post(
re.compile(r".*/aliases/"),
body=json.dumps({"detail": "Alias already exists"}),
json={"detail": "Alias already exists"},
status=status.HTTP_409_CONFLICT,
content_type="application/json",
)
@@ -150,17 +129,14 @@ def test_api_aliases_create__admins_ok(role, dimail_token_ok):
client.force_login(access.user)
# Prepare responses
# token response in fixtures
responses.add(
responses.POST,
responses.post(
re.compile(rf".*/domains/{access.domain.name}/aliases/"),
body=json.dumps(
{
"username": "contact",
"domain": access.domain.name,
"destination": "someone@outsidedomain.com",
"allow_to_send": True,
}
),
json={
"username": "contact",
"domain": access.domain.name,
"destination": "someone@outsidedomain.com",
"allow_to_send": True,
},
status=status.HTTP_201_CREATED,
content_type="application/json",
)
@@ -173,3 +149,64 @@ def test_api_aliases_create__admins_ok(role, dimail_token_ok):
alias = models.Alias.objects.get()
assert alias.local_part == "contact"
assert alias.destination == "someone@outsidedomain.com"
@responses.activate
def test_api_aliases_create__existing_mailbox_ok(dimail_token_ok):
"""Can create alias even if local_part is already used by a mailbox."""
access = factories.MailDomainAccessFactory(
role="owner", domain=factories.MailDomainEnabledFactory()
)
mailbox = factories.MailboxFactory(domain=access.domain)
client = APIClient()
client.force_login(access.user)
responses.post(
re.compile(rf".*/domains/{access.domain.name}/aliases/"),
json={
"username": mailbox.local_part,
"domain": access.domain.name,
"destination": "someone@outsidedomain.com",
"allow_to_send": False,
},
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/",
{"local_part": mailbox.local_part, "destination": "someone@outsidedomain.com"},
)
assert response.status_code == status.HTTP_201_CREATED
assert models.Alias.objects.exists()
@responses.activate
def test_api_aliases_create__devnull_destination_ok(dimail_token_ok):
"""Can create alias where destination is devnull@devnull."""
access = factories.MailDomainAccessFactory(
role="owner", domain=factories.MailDomainEnabledFactory()
)
client = APIClient()
client.force_login(access.user)
responses.post(
re.compile(rf".*/domains/{access.domain.name}/aliases/"),
json={
"username": "spammy-address",
"domain": access.domain.name,
"destination": "devnull@devnull",
"allow_to_send": False,
},
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/",
{"local_part": "spammy-address", "destination": "devnull@devnull"},
)
assert response.status_code == status.HTTP_201_CREATED
assert models.Alias.objects.exists()
@@ -1,6 +1,6 @@
"""
Tests for aliases API endpoint in People's app mailbox_manager.
Focus on "list" action.
Focus on "delete" action.
"""
# pylint: disable=W0613
@@ -20,11 +20,12 @@ pytestmark = pytest.mark.django_db
def test_api_aliases_delete__anonymous():
"""Anonymous user should not be able to delete aliases."""
alias = factories.AliasFactory()
alias_ = factories.AliasFactory()
response = APIClient().delete(
f"/api/v1.0/mail-domains/{alias.domain.slug}/aliases/{alias.local_part}/",
f"/api/v1.0/mail-domains/{alias_.domain.slug}/aliases/{alias_.pk}/"
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert models.Alias.objects.count() == 1
@@ -35,12 +36,12 @@ def test_api_aliases_delete__no_access_forbidden_not_found():
mail domain to which they are not related.
"""
authenticated_user = core_factories.UserFactory()
alias = factories.AliasFactory()
alias_ = factories.AliasFactory()
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{alias.domain.slug}/aliases/{alias.local_part}/",
f"/api/v1.0/mail-domains/{alias_.domain.slug}/aliases/{alias_.pk}/"
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@@ -56,59 +57,40 @@ def test_api_aliases_delete__viewer_forbidden():
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.VIEWER)]
)
alias = factories.AliasFactory(domain=mail_domain)
alias_ = factories.AliasFactory(domain=mail_domain)
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/{alias.local_part}/",
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/{alias_.pk}/"
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.Alias.objects.count() == 1
@responses.activate
def test_api_aliases_delete__viewer_can_delete_self_alias(dimail_token_ok):
"""
Authenticated users should be allowed to delete aliases when linking
to their own mailbox.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.VIEWER)]
)
alias = factories.AliasFactory(
domain=mail_domain, destination=authenticated_user.email
)
# Mock dimail response
responses.delete(
re.compile(r".*/aliases/"),
status=status.HTTP_204_NO_CONTENT,
content_type="application/json",
)
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/{alias.local_part}/",
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not models.Alias.objects.exists()
@responses.activate
def test_api_aliases_delete__administrators_allowed(dimail_token_ok):
"""
Administrators of a mail domain should be allowed to delete accesses excepted owner accesses.
Administrators of a mail domain should be allowed to delete aliases.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
)
alias = factories.AliasFactory(domain=mail_domain)
alias_ = factories.AliasFactory(domain=mail_domain)
factories.AliasFactory.create_batch(
2, domain=mail_domain, local_part=alias_.local_part
)
# additional aliases that shouldn't be affected
factories.AliasFactory.create_batch(
2, domain=mail_domain, destination=alias_.destination
)
factories.AliasFactory(
local_part=alias_.local_part,
destination=alias_.destination,
)
# Mock dimail response
responses.delete(
@@ -120,11 +102,10 @@ def test_api_aliases_delete__administrators_allowed(dimail_token_ok):
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/{alias.local_part}/",
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/{alias_.pk}/"
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not models.Alias.objects.exists()
assert models.Alias.objects.count() == 5
@responses.activate
@@ -136,8 +117,7 @@ def test_api_aliases_delete__404_out_of_sync(dimail_token_ok):
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
)
alias = factories.AliasFactory(domain=mail_domain)
alias_ = factories.AliasFactory(domain=mail_domain)
# Mock dimail response
responses.delete(
re.compile(r".*/aliases/"),
@@ -149,11 +129,11 @@ def test_api_aliases_delete__404_out_of_sync(dimail_token_ok):
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/{alias.local_part}/",
f"/api/v1.0/mail-domains/{mail_domain.slug}/aliases/{alias_.pk}/"
)
assert response.status_code == status.HTTP_200_OK
assert (
response.json()
== "Alias already deleted. Domain out of sync, please contact our support."
== "Domain out of sync with mailbox provider, please contact our support."
)
assert not models.Alias.objects.exists()
@@ -59,4 +59,7 @@ def test_api_aliases_list__authorized_ok(role):
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/",
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["results"] == sorted(
response.json()["results"], key=lambda x: x["local_part"]
)
assert response.json()["count"] == 5
@@ -0,0 +1,76 @@
"""
Tests for MailDomainInvitation API endpoint in People's app mailbox_manager.
Focus on "delete" action.
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from mailbox_manager import factories, models
pytestmark = pytest.mark.django_db
def test_api_domain_invitations__delete__anonymous():
"""Anonymous users should not be able to delete invitations."""
domain = factories.MailDomainEnabledFactory()
invitation = factories.MailDomainInvitationFactory()
response = APIClient().delete(
f"/api/v1.0/mail-domains/{domain.slug}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert response.json() == {
"detail": "Authentication credentials were not provided."
}
assert models.MailDomainInvitation.objects.count() == 1
def test_api_domain_invitations__delete__no_access_not_found():
"""Users should not be permitted to delete invitations
on domains they don't manage."""
domain = factories.MailDomainEnabledFactory()
invitation = factories.MailDomainInvitationFactory()
other_access = factories.MailDomainAccessFactory(role="owner") # unrelated access
client = APIClient()
client.force_login(other_access.user)
response = client.delete(
f"/api/v1.0/mail-domains/{domain.slug}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert models.MailDomainInvitation.objects.count() == 1
def test_api_domain_invitations__delete__viewers_forbidden():
"""Domain viewers should not be permitted to delete invitations."""
access = factories.MailDomainAccessFactory(role="viewer")
invitation = factories.MailDomainInvitationFactory(domain=access.domain)
client = APIClient()
client.force_login(access.user)
response = client.delete(
f"/api/v1.0/mail-domains/{access.domain.slug}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.MailDomainInvitation.objects.count() == 1
@pytest.mark.parametrize(
"role",
["owner", "administrator"],
)
def test_api_domain_invitations__delete_admins_ok(role):
"""Domain owners and admins should be able to delete invitations."""
access = factories.MailDomainAccessFactory(role=role)
invitation = factories.MailDomainInvitationFactory(domain=access.domain)
client = APIClient()
client.force_login(access.user)
response = client.delete(
f"/api/v1.0/mail-domains/{access.domain.slug}/invitations/{invitation.id}/",
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert not models.MailDomainInvitation.objects.exists()
@@ -68,8 +68,7 @@ def test_api_mail_domains__create_authenticated():
domain_name = "test.domain.fr"
responses.add(
responses.POST,
responses.post(
re.compile(r".*/domains/"),
body=str(
{
@@ -81,17 +80,15 @@ def test_api_mail_domains__create_authenticated():
)
body_content_domain1 = CHECK_DOMAIN_BROKEN.copy()
body_content_domain1["name"] = domain_name
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain_name}/check/"),
body=json.dumps(body_content_domain1),
json=body_content_domain1,
status=status.HTTP_200_OK,
content_type="application/json",
)
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain_name}/spec/"),
body=json.dumps(DOMAIN_SPEC),
json=DOMAIN_SPEC,
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -155,30 +152,25 @@ def test_api_mail_domains__create_dimail_domain(caplog):
client.force_login(user)
domain_name = "test.fr"
responses.add(
responses.POST,
responses.post(
re.compile(r".*/domains/"),
body=str(
{
"name": domain_name,
}
),
json={
"name": domain_name,
}
status=status.HTTP_201_CREATED,
content_type="application/json",
)
body_content_domain1 = CHECK_DOMAIN_OK.copy()
body_content_domain1["name"] = domain_name
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain_name}/check/"),
body=json.dumps(body_content_domain1),
json=body_content_domain1,
status=status.HTTP_200_OK,
content_type="application/json",
)
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain_name}/spec/"),
body=json.dumps(DOMAIN_SPEC),
json=DOMAIN_SPEC,
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -213,8 +205,7 @@ def test_api_mail_domains__no_creation_when_dimail_duplicate(caplog):
"status_code": status.HTTP_409_CONFLICT,
"detail": "Domain already exists",
}
responses.add(
responses.POST,
responses.post(
re.compile(r".*/domains/"),
body=str({"detail": dimail_error["detail"]}),
status=dimail_error["status_code"],
@@ -104,14 +104,12 @@ def test_api_mail_domains__fetch_from_dimail_admin_successful(role):
assert domain.expected_config is None
assert domain.last_check_details is None
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/check/"),
json=dimail_fixtures.CHECK_DOMAIN_OK,
status=200,
)
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/spec/"),
json=dimail_fixtures.DOMAIN_SPEC,
status=200,
@@ -122,8 +122,7 @@ def test_api_mailboxes__create_display_name_no_constraint_on_different_domains(
# ensure response
# token response in fixtures
responses.add(
responses.POST,
responses.post(
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body=response_mailbox_created(
f"{new_mailbox_data['local_part']}@{access.domain.name}"
@@ -160,8 +159,7 @@ def test_api_mailboxes__create_roles_success(role, dimail_token_ok, mailbox_data
client.force_login(access.user)
# Ensure successful response using "responses":
# token response in fixtures
responses.add(
responses.POST,
responses.post(
re.compile(rf".*/domains/{mail_domain.name}/mailboxes/"),
body=response_mailbox_created(
f"{mailbox_data['local_part']}@{mail_domain.name}"
@@ -210,8 +208,7 @@ def test_api_mailboxes__create_with_accent_success(role, dimail_token_ok):
# Ensure successful response using "responses":
# token response in fixtures
responses.add(
responses.POST,
responses.post(
re.compile(rf".*/domains/{mail_domain.name}/mailboxes/"),
body=response_mailbox_created(
f"{mailbox_values['local_part']}@{mail_domain.name}"
@@ -285,8 +282,7 @@ def test_api_mailboxes__create_without_secondary_email(role, caplog, dimail_toke
del mailbox_values["secondary_email"]
# token response in fixtures
responses.add(
responses.POST,
responses.post(
re.compile(rf".*/domains/{mail_domain.name}/mailboxes/"),
body=response_mailbox_created(
f"{mailbox_values['local_part']}@{mail_domain.name}"
@@ -386,8 +382,7 @@ def test_api_mailboxes__same_local_part_on_different_domains(dimail_token_ok):
factories.MailboxFactory.build(local_part=existing_mailbox.local_part)
).data
responses.add(
responses.POST,
responses.post(
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body=response_mailbox_created(
f"{mailbox_values['local_part']}@{access.domain.name}"
@@ -439,10 +434,9 @@ def test_api_mailboxes__create_pending_mailboxes(domain_status, mailbox_data):
assert mailbox.status == "pending"
def test_api_mailboxes__existing_alias_bad_request(mailbox_data):
"""
Cannot create mailbox if local_part is already used by an alias.
"""
@responses.activate
def test_api_mailboxes__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(
role=enums.MailDomainRoleChoices.ADMIN, domain=alias.domain
@@ -450,7 +444,16 @@ def test_api_mailboxes__existing_alias_bad_request(mailbox_data):
client = APIClient()
client.force_login(access.user)
# No response because we expect no outside calls to be made
responses.post(
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body=response_mailbox_created(
f"{mailbox_data['local_part']}@{access.domain.name}"
),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/",
{
@@ -461,11 +464,8 @@ def test_api_mailboxes__existing_alias_bad_request(mailbox_data):
},
format="json",
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json() == {
"local_part": [f'Local part "{alias.local_part}" already used by an alias.']
}
assert not models.Mailbox.objects.exists()
assert response.status_code == status.HTTP_201_CREATED
assert models.Mailbox.objects.exists()
### REACTING TO DIMAIL-API
@@ -515,8 +515,7 @@ def test_api_mailboxes__async_dimail_unauthorized(
client.force_login(access.user)
# Ensure successful response using "responses":
# token response in fixtures
responses.add(
responses.POST,
responses.post(
re.compile(
rf".*/domains/{access.domain.name}/mailboxes/{mailbox_data['local_part']}"
),
@@ -557,8 +556,7 @@ def test_api_mailboxes__domain_owner_or_admin_successful_creation_and_provisioni
client.force_login(access.user)
# Ensure successful response using "responses":
# token response in fixtures
responses.add(
responses.POST,
responses.post(
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body=response_mailbox_created(
f"{mailbox_data['local_part']}@{access.domain.name}"
@@ -615,8 +613,7 @@ def test_api_mailboxes__domain_owner_or_admin_successful_creation_sets_password(
client = APIClient()
client.force_login(access.user)
# Ensure successful response using "responses":
responses.add(
responses.POST,
responses.post(
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body=response_mailbox_created(
f"{mailbox_data['local_part']}@{access.domain.name}"
@@ -658,8 +655,7 @@ def test_api_mailboxes__dimail_token_permission_denied(caplog, mailbox_data):
client = APIClient()
client.force_login(access.user)
# Ensure successful response using "responses":
responses.add(
responses.GET,
responses.get(
re.compile(r".*/token/"),
body='{"details": "Permission denied"}',
status=status.HTTP_403_FORBIDDEN,
@@ -703,8 +699,7 @@ def test_api_mailboxes__user_unrelated_to_domain(dimail_token_ok, mailbox_data):
client.force_login(access.user)
# Ensure successful response using "responses":
# token response in fixtures
responses.add(
responses.POST,
responses.post(
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body='{"details": "Permission denied"}',
status=status.HTTP_403_FORBIDDEN,
@@ -739,15 +734,13 @@ def test_api_mailboxes__duplicate_display_name(dimail_token_ok, mailbox_data):
client.force_login(access.user)
# Ensure successful response using "responses":
# token response in fixtures
responses.add(
responses.POST,
responses.post(
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body='{"detail": "Internal server error"}',
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
content_type="application/json",
)
responses.add(
responses.GET,
responses.get(
re.compile(
rf".*/domains/{access.domain.name}/address/{mailbox_data['local_part']}"
),
@@ -796,15 +789,13 @@ def test_api_mailboxes__handling_dimail_unexpected_error(
client.force_login(access.user)
# Ensure successful response using "responses":
# token response in fixtures
responses.add(
responses.POST,
responses.post(
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body='{"detail": "Internal server error"}',
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
content_type="application/json",
)
responses.add(
responses.GET,
responses.get(
re.compile(
rf".*/domains/{access.domain.name}/address/{mailbox_data['local_part']}/"
),
@@ -847,15 +838,13 @@ def test_api_mailboxes__display_name_duplicate_error(dimail_token_ok, mailbox_da
client.force_login(access.user)
# Ensure successful response using "responses":
# token response in fixtures
responses.add(
responses.POST,
responses.post(
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body='{"detail": "Internal server error"}',
status=status.HTTP_500_INTERNAL_SERVER_ERROR,
content_type="application/json",
)
responses.add(
responses.GET,
responses.get(
re.compile(
rf".*/domains/{access.domain.name}/address/{mailbox_data['local_part']}/"
),
@@ -906,8 +895,7 @@ def test_api_mailboxes__send_correct_logger_infos(
client.force_login(access.user)
# Ensure successful response using "responses":
# token response in fixtures
responses.add(
responses.POST,
responses.post(
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body=response_mailbox_created(
f"{mailbox_data['local_part']}@{access.domain.name}"
@@ -957,8 +945,7 @@ def test_api_mailboxes__sends_new_mailbox_notification(
client.force_login(user)
# Ensure successful response using "responses":
# token response in fixtures
responses.add(
responses.POST,
responses.post(
re.compile(rf".*/domains/{access.domain.name}/mailboxes/"),
body=response_mailbox_created(f"{mailbox_data['local_part']}@{access.domain}"),
status=status.HTTP_201_CREATED,
@@ -113,7 +113,7 @@ Please add a valid secondary email before trying again."
],
)
@responses.activate
def test_api_mailboxes__reset_password_admin_successful(role):
def test_api_mailboxes__reset_password_admin_successful(role, dimail_token_ok):
"""Owner and admin users should be able to reset password on mailboxes.
New password should be sent to secondary email."""
mail_domain = factories.MailDomainEnabledFactory()
@@ -123,15 +123,7 @@ def test_api_mailboxes__reset_password_admin_successful(role):
client = APIClient()
client.force_login(access.user)
dimail_url = settings.MAIL_PROVISIONING_API_URL
responses.add(
responses.GET,
f"{dimail_url}/token/",
body=dimail.TOKEN_OK,
status=200,
)
responses.add(
responses.POST,
responses.post(
f"{dimail_url}/domains/{mail_domain.name}/mailboxes/{mailbox.local_part}/reset_password/",
body=dimail.response_mailbox_created(str(mailbox)),
status=200,
@@ -161,7 +153,7 @@ def test_api_mailboxes__reset_password_non_existing():
@responses.activate
def test_api_mailboxes__reset_password_connexion_failed():
def test_api_mailboxes__reset_password_connexion_failed(dimail_token_ok):
"""
No mail is sent when password reset failed because of connexion error.
"""
@@ -173,16 +165,8 @@ def test_api_mailboxes__reset_password_connexion_failed():
)
client = APIClient()
client.force_login(access.user)
dimail_url = settings.MAIL_PROVISIONING_API_URL
responses.add(
responses.GET,
f"{dimail_url}/token/",
body=dimail.TOKEN_OK,
status=200,
)
responses.add(
responses.POST,
responses.post(
f"{dimail_url}/domains/{mail_domain.name}/mailboxes/{mailbox.local_part}/reset_password/",
body=ConnectionError(),
)
@@ -48,8 +48,7 @@ def test_fetch_domain_status():
(domain_failed, body_content_ok3),
]:
# mock dimail API
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_content),
status=status.HTTP_200_OK,
@@ -9,17 +9,15 @@ import responses
from rest_framework import status
from mailbox_manager import factories
from mailbox_manager.tests.fixtures.dimail import TOKEN_OK
## DIMAIL RESPONSES
@pytest.fixture(name="dimail_token_ok")
def fixture_dimail_token_ok():
"""Mock dimail response when /token/ endpoit is given valid credentials."""
responses.add(
responses.GET,
responses.get(
re.compile(r".*/token/"),
body=TOKEN_OK,
json={"access_token": "token", "token_type": "bearer"},
status=status.HTTP_200_OK,
content_type="application/json",
)
-4
View File
@@ -287,10 +287,6 @@ DOMAIN_SPEC = [
]
## TOKEN
TOKEN_OK = json.dumps({"access_token": "token", "token_type": "bearer"})
## ALLOWS
def response_allows_created(user_name, domain_name):
"""mimic dimail response upon successful allows creation.
@@ -0,0 +1,19 @@
"""
Unit tests for the Alias model
"""
import pytest
from mailbox_manager import factories, models
pytestmark = pytest.mark.django_db
def test_models_aliases__devnull_destination_ok():
"""Can create alias where destination is devnull@devnull."""
models.Alias.objects.create(
local_part="spam",
domain=factories.MailDomainEnabledFactory(),
destination="devnull@devnull",
)
@@ -2,7 +2,6 @@
Unit tests for admin actions
"""
import json
import re
from django.urls import reverse
@@ -19,7 +18,6 @@ from .fixtures.dimail import (
CHECK_DOMAIN_BROKEN,
CHECK_DOMAIN_OK,
DOMAIN_SPEC,
TOKEN_OK,
response_mailbox_created,
)
@@ -76,17 +74,15 @@ def test_fetch_domain_status__should_switch_to_failed_when_domain_broken(client)
body_content_domain1["name"] = domain1.name
body_content_domain2 = CHECK_DOMAIN_BROKEN.copy()
body_content_domain2["name"] = domain2.name
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain1.name}/check/"),
body=json.dumps(body_content_domain1),
json=body_content_domain1,
status=status.HTTP_200_OK,
content_type="application/json",
)
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain2.name}/check/"),
body=json.dumps(body_content_domain2),
json=body_content_domain2,
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -101,7 +97,9 @@ def test_fetch_domain_status__should_switch_to_failed_when_domain_broken(client)
@responses.activate
@pytest.mark.django_db
def test_fetch_domain_status__should_switch_to_enabled_when_domain_ok(client):
def test_fetch_domain_status__should_switch_to_enabled_when_domain_ok(
client, dimail_token_ok
):
"""Test admin action should switch domain state to ENABLED
when dimail's response is "ok". It should also activate any pending mailbox."""
admin = core_factories.UserFactory(is_staff=True, is_superuser=True)
@@ -119,22 +117,14 @@ def test_fetch_domain_status__should_switch_to_enabled_when_domain_ok(client):
body_content_domain1 = CHECK_DOMAIN_OK.copy()
body_content_domain1["name"] = domain1.name
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain1.name}/check/"),
body=json.dumps(body_content_domain1),
json=body_content_domain1,
status=status.HTTP_200_OK,
content_type="application/json",
)
# we need to get a token to create mailboxes
responses.add(
responses.GET,
re.compile(r".*/token/"),
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
responses.add(
# token call in fixtures
responses.post(
responses.POST,
re.compile(rf".*/domains/{domain1.name}/mailboxes/"),
body=response_mailbox_created(f"truc@{domain1.name}"),
@@ -172,10 +162,9 @@ def test_fetch_domain_expected_config(client, domain_status):
"action": "fetch_domain_expected_config_from_dimail",
"_selected_action": [domain.id],
}
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/spec/"),
body=json.dumps(DOMAIN_SPEC),
json=DOMAIN_SPEC,
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -208,7 +197,7 @@ def test_fetch_domain_expected_config__should_not_fetch_for_disabled_domain(clie
@responses.activate
@pytest.mark.django_db
def test_send_pending_mailboxes(client):
def test_send_pending_mailboxes(client, dimail_token_ok):
"""Test admin action to send pending mailboxes to dimail."""
admin = core_factories.UserFactory(is_staff=True, is_superuser=True)
client.force_login(admin)
@@ -223,15 +212,8 @@ def test_send_pending_mailboxes(client):
url = reverse("admin:mailbox_manager_maildomain_changelist")
for mailbox in mailboxes:
responses.add(
responses.GET,
re.compile(r".*/token/"),
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
responses.add(
responses.POST,
# token call in fixture
responses.post(
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
body=response_mailbox_created(f"{mailbox.local_part}@{domain.name}"),
status=status.HTTP_201_CREATED,
@@ -247,7 +229,7 @@ def test_send_pending_mailboxes(client):
@responses.activate
@pytest.mark.django_db
def test_send_pending_mailboxes__listing_failed_mailboxes(client):
def test_send_pending_mailboxes__listing_failed_mailboxes(client, dimail_token_ok):
"""Test admin action to send pending mailboxes to dimail."""
admin = core_factories.UserFactory(is_staff=True, is_superuser=True)
client.force_login(admin)
@@ -261,15 +243,8 @@ def test_send_pending_mailboxes__listing_failed_mailboxes(client):
}
url = reverse("admin:mailbox_manager_maildomain_changelist")
responses.add(
responses.GET,
re.compile(r".*/token/"),
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
responses.add(
responses.POST,
# token call in fixtures
responses.post(
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
body=response_mailbox_created(f"{mailbox.local_part}@{domain.name}"),
status=status.HTTP_409_CONFLICT,
@@ -2,7 +2,6 @@
Unit tests for mailbox manager tasks.
"""
import json
import re
from unittest import mock
@@ -63,18 +62,16 @@ def test_fetch_domain_status_task_success(): # pylint: disable=too-many-locals
(domain_failed, body_content_ok3),
]:
# Mock dimail API with success response
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_content),
json=body_content,
status=200,
content_type="application/json",
)
# domain_enabled2 is broken with internal error, we try to fix it
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain_enabled2.name}/fix/"),
body=json.dumps(body_content_broken_internal),
json=body_content_broken_internal,
status=200,
content_type="application/json",
)
@@ -170,10 +167,9 @@ def test_fetch_domains_status_error_handling(caplog):
domain = factories.MailDomainEnabledFactory()
# Mock dimail API with error response
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps({"error": "Internal Server Error"}),
json={"error": "Internal Server Error"},
status=500,
content_type="application/json",
)
@@ -6,9 +6,6 @@ Unit tests for dimail client
import logging
import re
from email.errors import HeaderParseError, NonASCIILocalPartDefect
from logging import Logger
from unittest import mock
import pytest
import responses
@@ -68,20 +65,27 @@ def test_dimail_synchronization__already_sync(dimail_token_ok):
@responses.activate
@mock.patch.object(Logger, "warning")
def test_dimail_synchronization__synchronize_mailboxes(mock_warning, dimail_token_ok):
"""A mailbox existing solely on dimail should be synchronized
upon calling sync function on its domain"""
def test_dimail_synchronization__synchronize_mailboxes(caplog, dimail_token_ok): # pylint: disable=W0613, R0914
"""Importing mailboxes from dimail should synchronize valid mailboxes
and log errors for invalid ones."""
caplog.set_level(logging.INFO)
domain = factories.MailDomainEnabledFactory()
assert not models.Mailbox.objects.exists()
existing_alias = factories.AliasFactory(domain=domain)
dimail_client = DimailAPIClient()
# Ensure successful response using "responses":
# token response in fixtures
# successful token in fixtures
mailbox_valid = {
"type": "mailbox",
"status": "ok",
"email": f"validmailbox@{domain.name}",
"givenName": "Michael",
"surName": "Roch",
"displayName": "Michael Roch",
}
mailbox_oxadmin = {
"type": "mailbox",
"status": "broken",
"email": f"oxadmin@{domain.name}",
@@ -113,7 +117,7 @@ def test_dimail_synchronization__synchronize_mailboxes(mock_warning, dimail_toke
"surName": "Vang",
"displayName": "Jean Vang",
}
mailbox_existing_username = {
mailbox_existing_alias = {
"type": "mailbox",
"status": "broken",
"email": f"{existing_alias.local_part}@{domain.name}",
@@ -126,10 +130,11 @@ def test_dimail_synchronization__synchronize_mailboxes(mock_warning, dimail_toke
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
json=[
mailbox_valid,
mailbox_oxadmin,
mailbox_with_wrong_domain,
mailbox_with_invalid_domain,
mailbox_with_invalid_local_part,
mailbox_existing_username,
mailbox_existing_alias,
],
status=status.HTTP_200_OK,
content_type="application/json",
@@ -137,35 +142,31 @@ def test_dimail_synchronization__synchronize_mailboxes(mock_warning, dimail_toke
imported_mailboxes = dimail_client.import_mailboxes(domain)
# 3 imports failed: wrong domain, HeaderParseError, NonASCIILocalPartDefect
assert mock_warning.call_count == 3
# 4 imports failed: oxadmin, wrong domain, HeaderParseError, NonASCIILocalPartDefect
assert len(caplog.records) == 5
log_messages = [record.message for record in caplog.records]
# first we try to import email with a wrong domain
assert mock_warning.call_args_list[0][0] == (
"Import of email %s failed because of a wrong domain",
mailbox_with_wrong_domain["email"],
)
expected_messages = [
f"Not importing OX technical address: oxadmin@{domain.name}",
f"Import of email {mailbox_with_wrong_domain['email']} failed because of a wrong domain",
f"Import of email {mailbox_with_invalid_domain['email']} failed with error Invalid Domain",
f"Import of email {mailbox_with_invalid_local_part['email']} failed with error local-part \
contains non-ASCII characters)",
]
for message in expected_messages:
assert message in log_messages
# then we try to import email with invalid domain
invalid_mailbox_log = mock_warning.call_args_list[1][0]
assert invalid_mailbox_log[1] == mailbox_with_invalid_domain["email"]
assert isinstance(invalid_mailbox_log[2], HeaderParseError)
# finally we try to import email with non ascii local part
non_ascii_mailbox_log = mock_warning.call_args_list[2][0]
assert non_ascii_mailbox_log[1] == mailbox_with_invalid_local_part["email"]
assert isinstance(non_ascii_mailbox_log[2], NonASCIILocalPartDefect)
mailbox = models.Mailbox.objects.get()
assert mailbox.local_part == "oxadmin"
assert mailbox.status == enums.MailboxStatusChoices.ENABLED
assert imported_mailboxes == [mailbox_valid["email"]]
assert models.Mailbox.objects.count() == 2
assert imported_mailboxes == [
mailbox_valid["email"],
mailbox_existing_alias["email"],
]
@responses.activate
def test_dimail_synchronization__synchronize_aliases(dimail_token_ok): # pylint: disable=unused-argument
"""Should import aliases from dimail if they don't already exist
and if username is not already used for mailbox"""
"""Importing aliases from dimail should synchronize valid aliases
and log errors for invalid ones."""
alias = factories.AliasFactory()
dimail_client = DimailAPIClient()
@@ -177,11 +178,11 @@ def test_dimail_synchronization__synchronize_aliases(dimail_token_ok): # pylint
{
"username": "contact",
"domain": alias.domain.name,
"destination": alias.destination, # same destination
"destination": alias.destination, # same destination = ok
"allow_to_send": False,
},
{
"username": alias.local_part, # same username
"username": alias.local_part, # same username = ok
"domain": alias.domain.name,
"destination": "maheius.endorecles@somethingelse.com",
"allow_to_send": False,
@@ -192,12 +193,18 @@ def test_dimail_synchronization__synchronize_aliases(dimail_token_ok): # pylint
"destination": alias.destination,
"allow_to_send": False,
},
{ # username already used for a mailbox
{ # mailbox with same username = ok
"username": existing_mailbox.local_part,
"domain": alias.domain.name,
"destination": existing_mailbox.secondary_email,
"allow_to_send": False,
},
{ # alias to devnull@devnull
"username": "spam",
"domain": alias.domain.name,
"destination": "devnull@devnull",
"allow_to_send": False,
},
]
responses.get(
re.compile(rf".*/domains/{alias.domain.name}/aliases/"),
@@ -208,8 +215,8 @@ def test_dimail_synchronization__synchronize_aliases(dimail_token_ok): # pylint
imported_aliases = dimail_client.import_aliases(alias.domain)
assert len(imported_aliases) == 2
assert models.Alias.objects.count() == 3
assert len(imported_aliases) == 4
assert models.Alias.objects.count() == 5
@pytest.mark.parametrize(
+85 -67
View File
@@ -45,7 +45,7 @@ class DimailAPIClient:
API_CREDENTIALS = settings.MAIL_PROVISIONING_API_CREDENTIALS
API_TIMEOUT = settings.MAIL_PROVISIONING_API_TIMEOUT
def get_headers(self):
def _get_headers(self):
"""
Return Bearer token. Requires MAIL_PROVISIONING_API_CREDENTIALS setting,
to get a token from dimail /token/ endpoint.
@@ -82,7 +82,7 @@ class DimailAPIClient:
"Token denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
)
return self.raise_exception_for_unexpected_response(response)
return self._raise_exception_for_unexpected_response(response)
def create_domain(self, domain_name, request_user):
"""Send a domain creation request to dimail API."""
@@ -117,7 +117,7 @@ class DimailAPIClient:
)
return response
return self.raise_exception_for_unexpected_response(response)
return self._raise_exception_for_unexpected_response(response)
def create_mailbox(self, mailbox, request_user=None):
"""Send a CREATE mailbox request to mail provisioning API."""
@@ -130,7 +130,7 @@ class DimailAPIClient:
# displayName value has to be unique
"displayName": f"{mailbox.first_name} {mailbox.last_name}",
}
headers = self.get_headers()
headers = self._get_headers()
try:
response = session.post(
@@ -194,7 +194,7 @@ class DimailAPIClient:
}
)
return self.raise_exception_for_unexpected_response(response)
return self._raise_exception_for_unexpected_response(response)
def create_user(self, user_id):
"""Send a request to dimail, to create a new user there. In dimail, user ids are subs."""
@@ -231,7 +231,7 @@ class DimailAPIClient:
)
return response
return self.raise_exception_for_unexpected_response(response)
return self._raise_exception_for_unexpected_response(response)
def create_allow(self, user_id, domain_name):
"""Send a request to dimail for a new 'allow' between user and the domain."""
@@ -273,9 +273,9 @@ class DimailAPIClient:
)
return response
return self.raise_exception_for_unexpected_response(response)
return self._raise_exception_for_unexpected_response(response)
def raise_exception_for_unexpected_response(self, response):
def _raise_exception_for_unexpected_response(self, response):
"""Raise error when encountering an unexpected error in dimail."""
try:
error_content = json.loads(
@@ -299,7 +299,7 @@ class DimailAPIClient:
title, template_name, recipient, mailbox_data, issuer
)
def notify_mailbox_password_reset(self, recipient, mailbox_data, issuer=None):
def _notify_mailbox_password_reset(self, recipient, mailbox_data, issuer=None):
"""
Send email to notify of password reset
and send new password.
@@ -359,7 +359,7 @@ class DimailAPIClient:
try:
response = session.get(
f"{self.API_URL}/domains/{domain.name}/mailboxes/",
headers=self.get_headers(),
headers=self._get_headers(),
verify=True,
timeout=self.API_TIMEOUT,
)
@@ -372,46 +372,48 @@ class DimailAPIClient:
raise error
if response.status_code != status.HTTP_200_OK:
return self.raise_exception_for_unexpected_response(response)
return self._raise_exception_for_unexpected_response(response)
dimail_mailboxes = response.json()
known_mailboxes = models.Mailbox.objects.filter(domain=domain)
known_aliases = [
known_alias.local_part
for known_alias in models.Alias.objects.filter(domain=domain)
]
people_mailboxes = models.Mailbox.objects.filter(domain=domain)
imported_mailboxes = []
for dimail_mailbox in dimail_mailboxes:
if (
dimail_mailbox["email"]
not in [str(known_mailboxes) for known_mailboxes in known_mailboxes]
and dimail_mailbox["email"].split("@")[0] not in known_aliases
):
try:
# sometimes dimail api returns email from another domain,
# so we decide to exclude this kind of email
address = Address(addr_spec=dimail_mailbox["email"])
if address.domain == domain.name:
# creates a mailbox on our end
mailbox = models.Mailbox.objects.create(
first_name=dimail_mailbox["givenName"],
last_name=dimail_mailbox["surName"],
local_part=address.username,
domain=domain,
status=enums.MailboxStatusChoices.ENABLED,
password=make_password(None), # unusable password
)
imported_mailboxes.append(str(mailbox))
else:
logger.warning(
"Import of email %s failed because of a wrong domain",
dimail_mailbox["email"],
)
except (HeaderParseError, NonASCIILocalPartDefect) as err:
try:
address = Address(addr_spec=dimail_mailbox["email"])
except (HeaderParseError, NonASCIILocalPartDefect) as error:
logger.warning(
"Import of email %s failed with error %s",
dimail_mailbox["email"],
error,
)
continue
if address.username == "oxadmin":
logger.warning(
"Not importing OX technical address: %s", dimail_mailbox["email"]
)
continue
if str(address) not in [
str(people_mailbox) for people_mailbox in people_mailboxes
]:
# sometimes dimail api returns email from another domain,
# so we decide to exclude this kind of email
if address.domain == domain.name:
# creates a mailbox on our end
mailbox = models.Mailbox.objects.create(
first_name=dimail_mailbox["givenName"],
last_name=dimail_mailbox["surName"],
local_part=address.username,
domain=domain,
status=enums.MailboxStatusChoices.ENABLED,
password=make_password(None), # unusable password
)
imported_mailboxes.append(str(mailbox))
else:
logger.warning(
"Import of email %s failed with error %s",
"Import of email %s failed because of a wrong domain",
dimail_mailbox["email"],
err,
)
return imported_mailboxes
@@ -420,7 +422,7 @@ class DimailAPIClient:
response = session.patch(
f"{self.API_URL}/domains/{mailbox.domain.name}/mailboxes/{mailbox.local_part}",
json={"active": "no"},
headers=self.get_headers(),
headers=self._get_headers(),
verify=True,
timeout=self.API_TIMEOUT,
)
@@ -432,7 +434,7 @@ class DimailAPIClient:
request_user,
)
return response
return self.raise_exception_for_unexpected_response(response)
return self._raise_exception_for_unexpected_response(response)
def enable_mailbox(self, mailbox, request_user=None):
"""Send a request to enable a mailbox to dimail API"""
@@ -444,7 +446,7 @@ class DimailAPIClient:
"surName": mailbox.last_name,
"displayName": f"{mailbox.first_name} {mailbox.last_name}",
},
headers=self.get_headers(),
headers=self._get_headers(),
verify=True,
timeout=self.API_TIMEOUT,
)
@@ -456,7 +458,7 @@ class DimailAPIClient:
request_user,
)
return response
return self.raise_exception_for_unexpected_response(response)
return self._raise_exception_for_unexpected_response(response)
def send_pending_mailboxes(self, domain):
"""Send requests for all pending mailboxes of a domain. Returns a list of failed mailboxes for this domain."""
@@ -505,7 +507,7 @@ class DimailAPIClient:
raise error
if response.status_code == status.HTTP_200_OK:
return response.json()
return self.raise_exception_for_unexpected_response(response)
return self._raise_exception_for_unexpected_response(response)
def fix_domain(self, domain):
"""Send a request to dimail to fix a domain.
@@ -522,7 +524,7 @@ class DimailAPIClient:
str(domain),
)
return response.json()
return self.raise_exception_for_unexpected_response(response)
return self._raise_exception_for_unexpected_response(response)
def fetch_domain_status(self, domain):
"""Send a request to check and update status of a domain."""
@@ -647,7 +649,7 @@ class DimailAPIClient:
try:
response = session.post(
f"{self.API_URL}/domains/{mailbox.domain.name}/mailboxes/{mailbox.local_part}/reset_password/",
headers=self.get_headers(),
headers=self._get_headers(),
verify=True,
timeout=self.API_TIMEOUT,
)
@@ -661,7 +663,7 @@ class DimailAPIClient:
if response.status_code == status.HTTP_200_OK:
# send new password to secondary email
self.notify_mailbox_password_reset(
self._notify_mailbox_password_reset(
recipient=mailbox.secondary_email,
mailbox_data={
"email": response.json()["email"],
@@ -673,7 +675,7 @@ class DimailAPIClient:
mailbox,
)
return response
return self.raise_exception_for_unexpected_response(response)
return self._raise_exception_for_unexpected_response(response)
def create_alias(self, alias, request_user=None):
"""Send a Create alias request to mail provisioning API."""
@@ -682,7 +684,7 @@ class DimailAPIClient:
"user_name": alias.local_part,
"destination": alias.destination,
}
headers = self.get_headers()
headers = self._get_headers()
try:
response = session.post(
@@ -730,12 +732,12 @@ class DimailAPIClient:
}
)
return self.raise_exception_for_unexpected_response(response)
return self._raise_exception_for_unexpected_response(response)
def delete_alias(self, alias, request_user=None):
"""Send a Delete alias request to mail provisioning API."""
headers = self.get_headers()
headers = self._get_headers()
try:
response = session.delete(
@@ -778,10 +780,31 @@ class DimailAPIClient:
str(alias.domain),
)
# we don't raise error because we actually want this alias to be deleted
# to match dimail's states
return response
return self.raise_exception_for_unexpected_response(response)
return self._raise_exception_for_unexpected_response(response)
def delete_multiple_alias(self, local_part, domain_name):
"""Send a Delete alias request to mail provisioning API."""
try:
response = session.delete(
f"{self.API_URL}/domains/{domain_name}/aliases/{local_part}/all",
json={},
headers=self._get_headers(),
verify=True,
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
# response.raise_for_status()
return response
def import_aliases(self, domain):
"""Import aliases from dimail. Useful if people fall out of sync with dimail."""
@@ -789,7 +812,7 @@ class DimailAPIClient:
try:
response = session.get(
f"{self.API_URL}/domains/{domain.name}/aliases/",
headers=self.get_headers(),
headers=self._get_headers(),
verify=True,
timeout=self.API_TIMEOUT,
)
@@ -802,25 +825,20 @@ class DimailAPIClient:
raise error
if response.status_code != status.HTTP_200_OK:
return self.raise_exception_for_unexpected_response(response)
return self._raise_exception_for_unexpected_response(response)
incoming_aliases = response.json()
known_aliases = [
(known_alias.local_part, known_alias.destination)
for known_alias in models.Alias.objects.filter(domain=domain)
]
known_mailboxes = [
known_mailbox.local_part
for known_mailbox in models.Mailbox.objects.filter(domain=domain)
]
imported_aliases = []
for incoming_alias in incoming_aliases:
if (
incoming_alias["username"],
incoming_alias["destination"],
) not in known_aliases and incoming_alias[
"username"
] not in known_mailboxes:
) not in known_aliases:
try:
new_alias = models.Alias.objects.create(
local_part=incoming_alias["username"],
-20
View File
@@ -581,26 +581,6 @@ class Base(Configuration):
environ_name="MAIL_CHECK_DOMAIN_INTERVAL",
environ_prefix=None,
)
DNS_PROVISIONING_TARGET_ZONE = values.Value(
default=None,
environ_name="DNS_PROVISIONING_TARGET_ZONE",
environ_prefix=None,
)
DNS_PROVISIONING_API_URL = values.Value(
default="https://api.scaleway.com",
environ_name="DNS_PROVISIONING_API_URL",
environ_prefix=None,
)
DNS_PROVISIONING_RESOURCE_ID = values.Value(
default=None,
environ_name="DNS_PROVISIONING_RESOURCE_ID",
environ_prefix=None,
)
DNS_PROVISIONING_API_CREDENTIALS = values.Value(
default=None,
environ_name="DNS_PROVISIONING_API_CREDENTIALS",
environ_prefix=None,
)
MATRIX_BASE_HOME_SERVER = values.Value(
default="https://matrix.agent.dinum.tchap.gouv.fr",
environ_name="MATRIX_BASE_HOME_SERVER",
-13
View File
@@ -11,22 +11,9 @@ from core.plugins.registry import register_hook
from plugins.la_suite.hooks_utils.all_organizations import (
get_organization_name_and_metadata_from_siret,
)
from plugins.la_suite.hooks_utils.communes import CommuneCreation
@register_hook("organization_created")
def get_organization_name_and_metadata_from_siret_hook(organization):
"""After creating an organization, update the organization name & metadata."""
get_organization_name_and_metadata_from_siret(organization)
@register_hook("organization_created")
def commune_organization_created(organization):
"""After creating an organization, update the organization name."""
CommuneCreation().run_after_create(organization)
@register_hook("organization_access_granted")
def commune_organization_access_granted(organization_access):
"""After granting an organization access, check for needed domain access grant."""
CommuneCreation().run_after_grant_access(organization_access)
@@ -21,25 +21,45 @@ API_URL = "https://recherche-entreprises.api.gouv.fr/search?q={siret}"
def _get_organization_name_and_metadata_from_results(data, siret):
"""Return the organization name and metadata from the results of a SIRET search."""
org_metadata = {}
for result in data["results"]:
for organization in result["matching_etablissements"]:
if organization.get("siret") == siret:
org_metadata["is_public_service"] = result.get("complements", {}).get(
"est_service_public", False
)
org_metadata["is_commune"] = (
str(result.get("nature_juridique", "")) == "7210"
)
# Find matching organization
match = next(
(
(res, org)
for res in data.get("results", [])
for org in res.get("matching_etablissements", [])
if org.get("siret") == siret
),
None,
)
store_signs = organization.get("liste_enseignes") or []
if store_signs:
return store_signs[0].title(), org_metadata
if name := result.get("nom_raison_sociale"):
return name.title(), org_metadata
if not match:
logger.warning("No organization name found for SIRET %s", siret)
return None, {}
result, organization = match
# Extract metadata
is_commune = str(result.get("nature_juridique", "")) == "7210"
metadata = {
"is_public_service": result.get("complements", {}).get(
"est_service_public", False
),
"is_commune": is_commune,
}
# Extract name (priority: commune name > store signs > business name)
name = None
if is_commune:
name = result.get("siege", {}).get("libelle_commune")
if not name: # Fallback for non-communes OR if commune has no libelle_commune
store_signs = organization.get("liste_enseignes") or []
name = store_signs[0] if store_signs else result.get("nom_raison_sociale")
if name:
return name.title(), metadata
logger.warning("No organization name found for SIRET %s", siret)
return None, org_metadata
return None, metadata
def get_organization_name_and_metadata_from_siret(organization):
@@ -1,243 +0,0 @@
"""Organization related plugins."""
import logging
import re
from django.conf import settings
from django.utils.text import slugify
import requests
from requests.adapters import HTTPAdapter, Retry
from mailbox_manager.enums import MailDomainRoleChoices
from mailbox_manager.models import MailDomain, MailDomainAccess
logger = logging.getLogger(__name__)
class ApiCall:
"""Encapsulates a call to an external API"""
inputs: dict = {}
method: str = "GET"
base: str = ""
url: str = ""
params: dict = {}
headers: dict = {}
response_data = None
def execute(self):
"""Call the specified API endpoint with supplied parameters and record response"""
if self.method in ("POST", "PATCH"):
response = requests.request(
method=self.method,
url=f"{self.base}/{self.url}",
json=self.params,
headers=self.headers,
timeout=20,
)
else:
response = requests.request(
method=self.method,
url=f"{self.base}/{self.url}",
params=self.params,
headers=self.headers,
timeout=20,
)
self.response_data = response.json()
logger.info(
"API call: %s %s %s %s",
self.method,
self.url,
self.params,
self.response_data,
)
class CommuneCreation:
"""
This plugin handles setup tasks for French communes.
"""
_api_url = "https://recherche-entreprises.api.gouv.fr/search?q={siret}"
def get_organization_name_from_results(self, data, siret):
"""Return the organization name from the results of a SIRET search."""
for result in data["results"]:
nature = "nature_juridique"
commune = nature in result and result[nature] == "7210"
if commune:
return result["siege"]["libelle_commune"].title()
logger.warning("Not a commune: SIRET %s", siret)
return None
def dns_call(self, spec):
"""Call to add a DNS record"""
zone_name = self.zone_name(spec.inputs["name"])
records = [
{
"name": item["target"],
"type": item["type"].upper(),
"data": item["value"],
"ttl": 3600,
}
for item in spec.response_data
]
result = ApiCall()
result.method = "PATCH"
result.base = "https://api.scaleway.com"
result.url = f"/domain/v2beta1/dns-zones/{zone_name}/records"
result.params = {"changes": [{"add": {"records": records}}]}
result.headers = {"X-Auth-Token": settings.DNS_PROVISIONING_API_CREDENTIALS}
return result
def normalize_name(self, name: str) -> str:
"""Map the name to a standard form"""
name = re.sub("'", "-", name)
return slugify(name)
def zone_name(self, name: str) -> str:
"""Derive the zone name from the commune name"""
normalized = self.normalize_name(name)
return f"{normalized}.{settings.DNS_PROVISIONING_TARGET_ZONE}"
def complete_commune_creation(self, name: str) -> ApiCall:
"""Specify the tasks to be completed after a commune is created."""
inputs = {"name": self.normalize_name(name)}
create_zone = ApiCall()
create_zone.method = "POST"
create_zone.base = "https://api.scaleway.com"
create_zone.url = "/domain/v2beta1/dns-zones"
create_zone.params = {
"project_id": settings.DNS_PROVISIONING_RESOURCE_ID,
"domain": settings.DNS_PROVISIONING_TARGET_ZONE,
"subdomain": inputs["name"],
}
create_zone.headers = {
"X-Auth-Token": settings.DNS_PROVISIONING_API_CREDENTIALS
}
zone_name = self.zone_name(inputs["name"])
create_domain = ApiCall()
create_domain.method = "POST"
create_domain.base = settings.MAIL_PROVISIONING_API_URL
create_domain.url = "/domains/"
create_domain.params = {
"name": zone_name,
"delivery": "virtual",
"features": ["webmail", "mailbox"],
"context_name": zone_name,
}
create_domain.headers = {
"Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
}
spec_domain = ApiCall()
spec_domain.inputs = inputs
spec_domain.base = settings.MAIL_PROVISIONING_API_URL
spec_domain.url = f"/domains/{zone_name}/spec"
spec_domain.headers = {
"Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
}
return [create_zone, create_domain, spec_domain]
def complete_zone_creation(self, spec_call):
"""Specify the tasks to be performed to set up the zone."""
return self.dns_call(spec_call)
def run_after_create(self, organization):
"""After creating an organization, update the organization name."""
logger.info("In CommuneCreation")
if not organization.registration_id_list:
# No registration ID to convert...
return
# In the nominal case, there is only one registration ID because
# the organization has been created from it.
try:
# Retry logic as the API may be rate limited
s = requests.Session()
retries = Retry(total=5, backoff_factor=0.1, status_forcelist=[429])
s.mount("https://", HTTPAdapter(max_retries=retries))
siret = organization.registration_id_list[0]
response = s.get(self._api_url.format(siret=siret), timeout=10)
response.raise_for_status()
data = response.json()
name = self.get_organization_name_from_results(data, siret)
# Not a commune ?
if not name:
return
except requests.RequestException as exc:
logger.exception("%s: Unable to fetch organization name from SIRET", exc)
return
organization.name = name
organization.save(update_fields=["name", "updated_at"])
logger.info("Organization %s name updated to %s", organization, name)
zone_name = self.zone_name(name)
support = "support-regie@numerique.gouv.fr"
MailDomain.objects.get_or_create(name=zone_name, support_email=support)
# Compute and execute the rest of the process
tasks = self.complete_commune_creation(name)
for task in tasks:
task.execute()
last_task = self.complete_zone_creation(tasks[-1])
last_task.execute()
def complete_grant_access(self, sub, zone_name):
"""Specify the tasks to be completed after making a user admin"""
create_user = ApiCall()
create_user.method = "POST"
create_user.base = settings.MAIL_PROVISIONING_API_URL
create_user.url = "/users/"
create_user.params = {
"name": sub,
"password": "no",
"is_admin": False,
"perms": [],
}
create_user.headers = {
"Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
}
grant_access = ApiCall()
grant_access.method = "POST"
grant_access.base = settings.MAIL_PROVISIONING_API_URL
grant_access.url = "/allows/"
grant_access.params = {
"user": sub,
"domain": zone_name,
}
grant_access.headers = {
"Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
}
return [create_user, grant_access]
def run_after_grant_access(self, organization_access):
"""After granting an organization access, check for needed domain access grant."""
orga = organization_access.organization
user = organization_access.user
zone_name = self.zone_name(orga.name)
try:
domain = MailDomain.objects.get(name=zone_name)
except MailDomain.DoesNotExist:
domain = None
if domain:
MailDomainAccess.objects.create(
domain=domain, user=user, role=MailDomainRoleChoices.OWNER
)
tasks = self.complete_grant_access(user.sub, zone_name)
for task in tasks:
task.execute()
@@ -1,231 +0,0 @@
"""Tests for the CommuneCreation plugin."""
from django.conf import settings
from django.test.utils import override_settings
import pytest
import responses
from plugins.la_suite.hooks_utils.communes import ApiCall, CommuneCreation
pytestmark = pytest.mark.django_db
def test_extract_name_from_org_data_when_commune():
"""Test the name is extracted correctly for a French commune."""
data = {
"results": [
{
"nom_complet": "COMMUNE DE VARZY",
"nom_raison_sociale": "COMMUNE DE VARZY",
"siege": {
"libelle_commune": "VARZY",
"liste_enseignes": ["MAIRIE"],
"siret": "21580304000017",
},
"nature_juridique": "7210",
"matching_etablissements": [
{
"siret": "21580304000017",
"libelle_commune": "VARZY",
"liste_enseignes": ["MAIRIE"],
}
],
}
]
}
plugin = CommuneCreation()
name = plugin.get_organization_name_from_results(data, "21580304000017")
assert name == "Varzy"
def test_api_call_execution():
"""Test that calling execute() faithfully executes the API call"""
task = ApiCall()
task.method = "POST"
task.base = "https://some_host"
task.url = "some_url"
task.params = {"some_key": "some_value"}
task.headers = {"Some-Header": "Some-Header-Value"}
with responses.RequestsMock() as rsps:
rsps.add(
rsps.POST,
url="https://some_host/some_url",
body='{"some_key": "some_value"}',
content_type="application/json",
headers={"Some-Header": "Some-Header-Value"},
)
task.execute()
@override_settings(DNS_PROVISIONING_TARGET_ZONE="collectivite.fr")
def test_tasks_on_commune_creation_include_zone_creation():
"""Test the first task in commune creation: creating the DNS sub-zone"""
plugin = CommuneCreation()
name = "Varzy"
tasks = plugin.complete_commune_creation(name)
assert tasks[0].base == "https://api.scaleway.com"
assert tasks[0].url == "/domain/v2beta1/dns-zones"
assert tasks[0].method == "POST"
assert tasks[0].params == {
"project_id": settings.DNS_PROVISIONING_RESOURCE_ID,
"domain": "collectivite.fr",
"subdomain": "varzy",
}
assert tasks[0].headers["X-Auth-Token"] == settings.DNS_PROVISIONING_API_CREDENTIALS
@override_settings(DNS_PROVISIONING_TARGET_ZONE="collectivite.fr")
def test_tasks_on_commune_creation_include_dimail_domain_creation():
"""Test the second task in commune creation: creating the domain in Dimail"""
plugin = CommuneCreation()
name = "Merlaut"
tasks = plugin.complete_commune_creation(name)
assert tasks[1].base == settings.MAIL_PROVISIONING_API_URL
assert tasks[1].url == "/domains/"
assert tasks[1].method == "POST"
assert tasks[1].params == {
"name": "merlaut.collectivite.fr",
"delivery": "virtual",
"features": ["webmail", "mailbox"],
"context_name": "merlaut.collectivite.fr",
}
assert (
tasks[1].headers["Authorization"]
== f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
)
@override_settings(DNS_PROVISIONING_TARGET_ZONE="collectivite.fr")
def test_tasks_on_commune_creation_include_fetching_spec():
"""Test the third task in commune creation: asking Dimail for the spec"""
plugin = CommuneCreation()
name = "Loc-Eguiner"
tasks = plugin.complete_commune_creation(name)
assert tasks[2].base == settings.MAIL_PROVISIONING_API_URL
assert tasks[2].url == "/domains/loc-eguiner.collectivite.fr/spec"
assert tasks[2].method == "GET"
assert (
tasks[2].headers["Authorization"]
== f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
)
@override_settings(DNS_PROVISIONING_TARGET_ZONE="collectivite.fr")
def test_tasks_on_commune_creation_include_dns_records():
"""Test the next several tasks in commune creation: creating records"""
plugin = CommuneCreation()
name = "Abidos"
spec_response = [
{"target": "", "type": "mx", "value": "mx.dev.ox.numerique.gouv.fr."},
{
"target": "dimail._domainkey",
"type": "txt",
"value": "v=DKIM1; h=sha256; k=rsa; p=MIICIjANB<truncated>AAQ==",
},
{"target": "imap", "type": "cname", "value": "imap.dev.ox.numerique.gouv.fr."},
{"target": "smtp", "type": "cname", "value": "smtp.dev.ox.numerique.gouv.fr."},
{
"target": "",
"type": "txt",
"value": "v=spf1 include:_spf.dev.ox.numerique.gouv.fr -all",
},
{
"target": "webmail",
"type": "cname",
"value": "webmail.dev.ox.numerique.gouv.fr.",
},
]
tasks = plugin.complete_commune_creation(name)
tasks[2].response_data = spec_response
expected = {
"changes": [
{
"add": {
"records": [
{
"name": item["target"],
"type": item["type"].upper(),
"data": item["value"],
"ttl": 3600,
}
for item in spec_response
]
}
}
]
}
zone_call = plugin.complete_zone_creation(tasks[2])
assert zone_call.params == expected
assert zone_call.url == "/domain/v2beta1/dns-zones/abidos.collectivite.fr/records"
assert (
zone_call.headers["X-Auth-Token"] == settings.DNS_PROVISIONING_API_CREDENTIALS
)
@override_settings(DNS_PROVISIONING_TARGET_ZONE="collectivite.fr")
def test_tasks_on_grant_access():
"""Test the final tasks after making user admin of an org"""
plugin = CommuneCreation()
tasks = plugin.complete_grant_access("some-sub", "mezos.collectivite.fr")
assert tasks[0].base == settings.MAIL_PROVISIONING_API_URL
assert tasks[0].url == "/users/"
assert tasks[0].method == "POST"
assert tasks[0].params == {
"name": "some-sub",
"password": "no",
"is_admin": False,
"perms": [],
}
assert (
tasks[0].headers["Authorization"]
== f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
)
assert tasks[1].base == settings.MAIL_PROVISIONING_API_URL
assert tasks[1].url == "/allows/"
assert tasks[1].method == "POST"
assert tasks[1].params == {
"user": "some-sub",
"domain": "mezos.collectivite.fr",
}
assert (
tasks[1].headers["Authorization"]
== f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
)
def test_normalize_name():
"""Test name normalization"""
plugin = CommuneCreation()
assert plugin.normalize_name("Asnières-sur-Saône") == "asnieres-sur-saone"
assert plugin.normalize_name("Bâgé-le-Châtel") == "bage-le-chatel"
assert plugin.normalize_name("Courçais") == "courcais"
assert plugin.normalize_name("Moÿ-de-l'Aisne") == "moy-de-l-aisne"
assert plugin.normalize_name("Salouël") == "salouel"
assert (
plugin.normalize_name("Bors (Canton de Tude-et-Lavalette)")
== "bors-canton-de-tude-et-lavalette"
)
@override_settings(DNS_PROVISIONING_TARGET_ZONE="collectivite.fr")
def test_zone_name():
"""Test transforming a commune name to a sub-zone of collectivite.fr"""
plugin = CommuneCreation()
assert plugin.zone_name("Bâgé-le-Châtel") == "bage-le-chatel.collectivite.fr"
@@ -61,8 +61,7 @@ def test_organization_plugins_run_after_create(
hook_settings, nature_juridique, is_commune, is_public_service
):
"""Test the run_after_create method of the organization plugins for nominal case."""
responses.add(
responses.GET,
responses.get(
"https://recherche-entreprises.api.gouv.fr/search?q=12345678901234",
json={
"results": [
@@ -106,8 +105,7 @@ def test_organization_plugins_run_after_create(
@responses.activate
def test_organization_plugins_run_after_create_api_fail(hook_settings):
"""Test the plugin when the API call fails."""
responses.add(
responses.GET,
responses.get(
"https://recherche-entreprises.api.gouv.fr/search?q=12345678901234",
json={"error": "Internal Server Error"},
status=500,
@@ -139,8 +137,7 @@ def test_organization_plugins_run_after_create_api_fail(hook_settings):
)
def test_organization_plugins_run_after_create_missing_data(hook_settings, results):
"""Test the plugin when the API call returns missing data."""
responses.add(
responses.GET,
responses.get(
"https://recherche-entreprises.api.gouv.fr/search?q=12345678901234",
json=results,
status=200,
@@ -168,8 +165,7 @@ def test_organization_plugins_run_after_create_no_list_enseignes(
hook_settings,
):
"""Test the run_after_create method of the organization plugins for nominal case."""
responses.add(
responses.GET,
responses.get(
"https://recherche-entreprises.api.gouv.fr/search?q=12345678901234",
json={
"results": [
@@ -26,15 +26,6 @@ def test_hooks_loaded():
]
assert organization_created_hook_names == [
"get_organization_name_and_metadata_from_siret_hook",
"commune_organization_created",
]
organization_access_granted_hook_names = [
callback.__name__
for callback in registry.get_callbacks("organization_access_granted")
]
assert organization_access_granted_hook_names == [
"commune_organization_access_granted"
]
# cleanup the hooks
+29 -28
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "people"
version = "1.21.0"
version = "1.22.2"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -27,40 +27,41 @@ requires-python = ">=3.11"
dependencies = [
"Brotli==1.2.0",
"PyJWT==2.10.1",
"boto3==1.40.17",
"celery[redis]==5.5.3",
"boto3==1.42.24",
"celery[redis]==5.6.2",
"django-celery-beat==2.8.1",
"django-celery-results==2.6.0",
"django-configurations==2.5.1",
"django-cors-headers==4.7.0",
"django-countries==7.6.1",
"django-cors-headers==4.9.0",
"django-countries==8.2.0",
"django-extensions==4.1",
"django-lasuite==0.0.12",
"django-oauth-toolkit==3.0.1",
"django-lasuite==0.0.22",
"django-oauth-toolkit==3.2.0",
"django-parler==2.3",
"django-redis==6.0.0",
"django-storages==1.14.6",
"django-timezone-field>=5.1",
"django-treebeard==4.7.1",
"django-zxcvbn-password-validator==1.4.5",
"django-treebeard==4.8.0",
"django-zxcvbn-password-validator==1.5.2",
"django==5.2.9",
"djangorestframework==3.16.1",
"dockerflow==2024.4.2",
"drf_spectacular==0.28.0",
"drf_spectacular[sidecar]==0.28.0",
"drf_spectacular==0.29.0",
"drf_spectacular[sidecar]==0.29.0",
"easy_thumbnails==2.10.1",
"factory_boy==3.3.3",
"flower==2.0.1",
"gunicorn==23.0.0",
"joserfc==1.3.0",
"jsonschema==4.25.1",
"mozilla-django-oidc==4.0.1",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.2.9",
"redis<=6.4.0",
"jaraco.context>=6.1.0",
"joserfc==1.6.1",
"jsonschema==4.26.0",
"mozilla-django-oidc==5.0.2",
"nested-multipart-parser==1.6.0",
"psycopg[binary]==3.3.2",
"redis<=7.1.0",
"requests==2.32.5",
"sentry-sdk[django]==2.35.1",
"whitenoise==6.9.0",
"sentry-sdk[django]==2.49.0",
"whitenoise==6.11.0",
]
[project.urls]
@@ -71,21 +72,21 @@ dependencies = [
[project.optional-dependencies]
dev = [
"drf-spectacular-sidecar==2025.8.1",
"drf-spectacular-sidecar==2026.1.1",
"ipdb==0.13.13",
"ipython==9.4.0",
"ipython==9.9.0",
"jq==1.10.0",
"pyfakefs==5.9.2",
"pylint-django==2.6.1",
"pylint==3.3.8",
"pytest-cov==6.2.1",
"pyfakefs==6.0.0",
"pylint-django==2.7.0",
"pylint==4.0.4",
"pytest-cov==7.0.0",
"pytest-django==4.11.1",
"pytest==8.4.1",
"pytest==9.0.2",
"pytest-icdiff==0.9",
"pytest-xdist==3.8.0",
"responses==0.25.8",
"ruff==0.12.10",
"types-requests==2.32.4.20250809",
"ruff==0.14.11",
"types-requests==2.32.4.20260107",
"freezegun==1.5.5",
]
+1 -1
View File
@@ -1 +1 @@
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
+3 -3
View File
@@ -1,6 +1,6 @@
{
"name": "app-desk",
"version": "1.21.0",
"version": "1.22.2",
"private": true,
"scripts": {
"dev": "next dev",
@@ -22,9 +22,9 @@
"@tanstack/react-query": "5.90.5",
"i18next": "25.6.0",
"i18next-browser-languagedetector": "8.2.0",
"lodash": "4.17.21",
"lodash": "4.17.23",
"luxon": "3.7.2",
"next": "15.4.8",
"next": "15.4.10",
"react": "*",
"react-dom": "*",
"react-hook-form": "7.65.0",
@@ -19,13 +19,17 @@ export const Input = ({ label, error, required, ...props }: InputProps) => {
>
{label} {required && '*'}
</label>
{error && (
<Text $size="xs" $theme="danger" $variation="600">
{error}
</Text>
)}
<input
id={label}
aria-required={required}
required={required}
style={{
padding: '12px',
margin: '6px 0',
borderRadius: '4px',
fontSize: '14px',
border: `1px solid ${error ? colorsTokens()['danger-500'] : colorsTokens()['greyscale-400']}`,
@@ -34,11 +38,6 @@ export const Input = ({ label, error, required, ...props }: InputProps) => {
}}
{...props}
/>
{error && (
<Text $size="xs" $color="error-500">
{error}
</Text>
)}
</Box>
);
};
@@ -11,3 +11,4 @@ export * from './Tag';
export * from './Text';
export * from './TextErrors';
export * from './separators';
export * from './tabs/CustomTabs';
@@ -27,7 +27,7 @@ export const CustomTabs = ({ tabs }: Props) => {
const id = tab.id ?? tab.label;
return (
<Tab key={id} aria-label={tab.ariaLabel} id={id}>
<Box $direction="row" $align="center" $gap="5px">
<Box $direction="row" $gap="5px">
{tab.iconName && (
<span className="material-icons" aria-hidden="true">
{tab.iconName}
@@ -1,63 +1,69 @@
.customTabsContainer {
:global {
.react-aria-TabList {
display: flex;
display: flex;
width: 100%;
margin-top: 30px;
&[data-orientation='horizontal'] {
.react-aria-Tab {
border-bottom: 2px solid var(--c--theme--colors--greyscale-500);
}
}
:global(.react-aria-Tabs) {
width: 100%;
display: flex;
flex: 1;
&[data-orientation='horizontal'] {
flex-direction: column;
}
}
:global(.react-aria-TabList) {
display: flex;
width: 100%;
gap: 25px;
}
:global(.react-aria-Tab) {
display: flex;
padding: 0 10px 10px 10px;
cursor: pointer;
outline: none;
position: relative;
color: var(--c--theme--colors--secondary-900);
transition: color 200ms;
--border-color: transparent;
forced-color-adjust: none;
&[data-hovered] {
background-color: #fff;
color: var(--text-color-hover);
}
.react-aria-Tab {
padding: 10px;
cursor: pointer;
outline: none;
position: relative;
color: var(--c--theme--colors--greyscale-700);
transition: color 200ms;
--border-color: transparent;
forced-color-adjust: none;
&[data-hovered],
&[data-focused] {
color: var(--c--theme--colors--greyscale-900);
}
&[data-selected] {
font-weight: 500;
color: var(--c--theme--colors--primary-text);
border-bottom: 2px solid var(--c--theme--colors--primary-text);
}
&[data-disabled] {
color: var(--text-color-disabled);
&[data-selected] {
border-bottom: 2px solid var(--c--theme--colors--primary-600) !important;
color: var(--c--theme--colors--primary-600);
}
&[data-disabled] {
color: var(--c--theme--colors--greyscale-500);
&[data-selected] {
--border-color: var(--c--theme--colors--greyscale-200);
}
}
&[data-focus-visible]::after {
content: '';
position: absolute;
inset: 4px;
border-radius: 4px;
border: 1px solid var(--c--theme--colors--primary-600);
--border-color: var(--text-color-disabled);
}
}
.react-aria-TabPanel {
margin-top: 4px;
padding: 10px;
&[data-focus-visible]:after {
content: '';
position: absolute;
inset: 4px;
border-radius: 4px;
outline: none;
border: 2px solid var(--focus-ring-color);
}
}
&[data-focus-visible] {
outline: 2px solid var(--c--theme--colors--primary-600);
}
:global(.react-aria-TabPanel) {
margin-top: 15px;
border-radius: 4px;
outline: none;
&[data-focus-visible] {
outline: 2px solid var(--focus-ring-color);
}
}
}
@@ -75,8 +75,7 @@ describe('useDeleteMailDomainAccess', () => {
expect(onSuccess).toHaveBeenCalledWith(
undefined,
{ slug: 'example-slug', accessId: '1-1-1-1-1' },
undefined,
{ client: {}, meta: undefined, mutationKey: undefined },
undefined, // context
),
);
expect(fetchMock.lastUrl()).toContain(
@@ -104,8 +104,7 @@ describe('useUpdateMailDomainAccess', () => {
expect(onSuccess).toHaveBeenCalledWith(
mockResponse, // data
{ slug: 'example-slug', accessId: '1-1-1-1-1', role: Role.VIEWER }, // variables
undefined, // onMutateResult
{ client: {}, meta: undefined, mutationKey: undefined }, // context
undefined, // context
),
);
expect(fetchMock.lastUrl()).toContain(
@@ -44,19 +44,42 @@ export const useCreateMailDomainAccess = (
options?: UseCreateMailDomainAccessOptions,
) => {
const queryClient = useQueryClient();
const {
onSuccess: optionsOnSuccess,
onError: optionsOnError,
...restOptions
} = options || {};
return useMutation<Access, APIError, CreateMailDomainAccessProps>({
mutationFn: createMailDomainAccess,
...options,
onSuccess: (data, variables, onMutateResult, context) => {
...restOptions,
onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_MAIL_DOMAIN_ACCESSES],
});
void queryClient.invalidateQueries({ queryKey: [KEY_MAIL_DOMAIN] });
options?.onSuccess?.(data, variables, onMutateResult, context);
if (optionsOnSuccess) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
optionsOnSuccess as unknown as (
data: Access,
variables: CreateMailDomainAccessProps,
context: unknown,
) => void
)(data, variables, context);
}
},
onError: (error, variables, onMutateResult, context) => {
options?.onError?.(error, variables, onMutateResult, context);
onError: (error, variables, context) => {
if (optionsOnError) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
optionsOnError as unknown as (
error: APIError,
variables: CreateMailDomainAccessProps,
context: unknown,
) => void
)(error, variables, context);
}
},
});
};
@@ -46,10 +46,15 @@ export const useDeleteMailDomainAccess = (
options?: UseDeleteMailDomainAccessOptions,
) => {
const queryClient = useQueryClient();
const {
onSuccess: optionsOnSuccess,
onError: optionsOnError,
...restOptions
} = options || {};
return useMutation<void, APIError, DeleteMailDomainAccessProps>({
mutationFn: deleteMailDomainAccess,
...options,
onSuccess: (data, variables, onMutateResult, context) => {
...restOptions,
onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_MAIL_DOMAIN_ACCESSES],
});
@@ -59,13 +64,27 @@ export const useDeleteMailDomainAccess = (
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_MAIL_DOMAIN],
});
if (options?.onSuccess) {
options.onSuccess(data, variables, onMutateResult, context);
if (optionsOnSuccess) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
optionsOnSuccess as unknown as (
data: void,
variables: DeleteMailDomainAccessProps,
context: unknown,
) => void
)(data, variables, context);
}
},
onError: (error, variables, onMutateResult, context) => {
if (options?.onError) {
options.onError(error, variables, onMutateResult, context);
onError: (error, variables, context) => {
if (optionsOnError) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
optionsOnError as unknown as (
error: APIError,
variables: DeleteMailDomainAccessProps,
context: unknown,
) => void
)(error, variables, context);
}
},
});
@@ -51,23 +51,42 @@ export const useUpdateMailDomainAccess = (
options?: UseUpdateMailDomainAccessOptions,
) => {
const queryClient = useQueryClient();
const {
onSuccess: optionsOnSuccess,
onError: optionsOnError,
...restOptions
} = options || {};
return useMutation<Access, APIError, UpdateMailDomainAccessProps>({
mutationFn: updateMailDomainAccess,
...options,
onSuccess: (data, variables, onMutateResult, context) => {
...restOptions,
onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_MAIL_DOMAIN_ACCESSES],
});
void queryClient.invalidateQueries({
queryKey: [KEY_MAIL_DOMAIN],
});
if (options?.onSuccess) {
options.onSuccess(data, variables, onMutateResult, context);
if (optionsOnSuccess) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
optionsOnSuccess as unknown as (
data: Access,
variables: UpdateMailDomainAccessProps,
context: unknown,
) => void
)(data, variables, context);
}
},
onError: (error, variables, onMutateResult, context) => {
if (options?.onError) {
options.onError(error, variables, onMutateResult, context);
onError: (error, variables, context) => {
if (optionsOnError) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
optionsOnError as unknown as (
error: APIError,
variables: UpdateMailDomainAccessProps,
context: unknown,
) => void
)(error, variables, context);
}
},
});
@@ -0,0 +1,4 @@
export * from './useAliases';
export * from './useAliasesInfinite';
export * from './useCreateAlias';
export * from './useDeleteAlias';
@@ -0,0 +1,57 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
import { Alias } from '../types';
export type MailDomainAliasesParams = {
mailDomainSlug: string;
page: number;
ordering?: string;
};
type MailDomainAliasesResponse = APIList<Alias>;
export const getMailDomainAliases = async ({
mailDomainSlug,
page,
ordering,
}: MailDomainAliasesParams): Promise<MailDomainAliasesResponse> => {
let url = `mail-domains/${mailDomainSlug}/aliases/?page=${page}`;
if (ordering) {
url += '&ordering=' + ordering;
}
const response = await fetchAPI(url);
if (!response.ok) {
throw new APIError(
`Failed to get the aliases of mail domain ${mailDomainSlug}`,
await errorCauses(response),
);
}
return response.json() as Promise<MailDomainAliasesResponse>;
};
export const KEY_LIST_ALIAS = 'aliases';
export function useAliases(
param: MailDomainAliasesParams,
queryConfig?: UseQueryOptions<
MailDomainAliasesResponse,
APIError,
MailDomainAliasesResponse
>,
) {
return useQuery<
MailDomainAliasesResponse,
APIError,
MailDomainAliasesResponse
>({
queryKey: [KEY_LIST_ALIAS, param],
queryFn: () => getMailDomainAliases(param),
...queryConfig,
});
}
@@ -0,0 +1,32 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { KEY_LIST_ALIAS, getMailDomainAliases } from './useAliases';
export type MailDomainAliasesInfiniteParams = {
mailDomainSlug: string;
ordering?: string;
};
export function useAliasesInfinite(
param: MailDomainAliasesInfiniteParams,
queryConfig = {},
) {
return useInfiniteQuery({
initialPageParam: 1,
queryKey: [KEY_LIST_ALIAS, param],
queryFn: ({ pageParam }) =>
getMailDomainAliases({
mailDomainSlug: param.mailDomainSlug,
page: pageParam,
ordering: param.ordering,
}),
getNextPageParam(lastPage, allPages) {
// When there is no more page, return undefined
if (!lastPage.next) {
return undefined;
}
return allPages.length + 1;
},
...queryConfig,
});
}
@@ -0,0 +1,83 @@
import {
UseMutationOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_LIST_ALIAS } from './useAliases';
export interface CreateAliasParams {
local_part: string;
destination: string;
mailDomainSlug: string;
}
export const createAlias = async ({
mailDomainSlug,
...data
}: CreateAliasParams): Promise<void> => {
const response = await fetchAPI(`mail-domains/${mailDomainSlug}/aliases/`, {
method: 'POST',
body: JSON.stringify(data),
});
if (!response.ok) {
const errorData = await errorCauses(response);
throw new APIError('Failed to create the alias', {
status: errorData.status,
cause: errorData.cause as string[],
data: errorData.data,
});
}
};
type UseCreateAliasParams = { mailDomainSlug: string } & UseMutationOptions<
void,
APIError,
CreateAliasParams
>;
export const useCreateAlias = (options: UseCreateAliasParams) => {
const queryClient = useQueryClient();
const {
onSuccess: optionsOnSuccess,
onError: optionsOnError,
...restOptions
} = options;
return useMutation<void, APIError, CreateAliasParams>({
mutationFn: createAlias,
...restOptions,
onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({
queryKey: [
KEY_LIST_ALIAS,
{ mailDomainSlug: variables.mailDomainSlug },
],
});
if (optionsOnSuccess) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
optionsOnSuccess as unknown as (
data: void,
variables: CreateAliasParams,
context: unknown,
) => void
)(data, variables, context);
}
},
onError: (error, variables, context) => {
if (optionsOnError) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
optionsOnError as unknown as (
error: APIError,
variables: CreateAliasParams,
context: unknown,
) => void
)(error, variables, context);
}
},
});
};
@@ -0,0 +1,79 @@
import {
UseMutationOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_LIST_ALIAS } from './useAliases';
interface DeleteAliasParams {
mailDomainSlug: string;
localPart: string;
}
export const deleteAlias = async ({
mailDomainSlug,
localPart,
}: DeleteAliasParams): Promise<void> => {
const response = await fetchAPI(
`mail-domains/${mailDomainSlug}/aliases/delete/?local_part=${encodeURIComponent(localPart)}`,
{
method: 'DELETE',
},
);
if (!response.ok) {
throw new APIError(
'Failed to delete the alias',
await errorCauses(response),
);
}
};
type UseDeleteAliasOptions = UseMutationOptions<
void,
APIError,
DeleteAliasParams
>;
export const useDeleteAlias = (options?: UseDeleteAliasOptions) => {
const queryClient = useQueryClient();
const {
onSuccess: optionsOnSuccess,
onError: optionsOnError,
...restOptions
} = options || {};
return useMutation<void, APIError, DeleteAliasParams>({
mutationFn: deleteAlias,
...restOptions,
onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_ALIAS],
});
if (optionsOnSuccess) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
optionsOnSuccess as unknown as (
data: void,
variables: DeleteAliasParams,
context: unknown,
) => void
)(data, variables, context);
}
},
onError: (error, variables, context) => {
if (optionsOnError) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
optionsOnError as unknown as (
error: APIError,
variables: DeleteAliasParams,
context: unknown,
) => void
)(error, variables, context);
}
},
});
};
@@ -0,0 +1,75 @@
import {
UseMutationOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_LIST_ALIAS } from './useAliases';
interface DeleteAliasByIdParams {
mailDomainSlug: string;
aliasId: string;
}
export const deleteAliasById = async ({
mailDomainSlug,
aliasId,
}: DeleteAliasByIdParams): Promise<void> => {
// Use aliasId (pk) directly in URL as per API lookup_field = "pk"
const response = await fetchAPI(
`mail-domains/${mailDomainSlug}/aliases/${aliasId}/`,
{
method: 'DELETE',
},
);
if (!response.ok) {
throw new APIError(
'Failed to delete the alias',
await errorCauses(response),
);
}
};
type UseDeleteAliasByIdOptions = UseMutationOptions<
void,
APIError,
DeleteAliasByIdParams
>;
export const useDeleteAliasById = (options?: UseDeleteAliasByIdOptions) => {
const queryClient = useQueryClient();
return useMutation<void, APIError, DeleteAliasByIdParams>({
mutationFn: deleteAliasById,
...options,
onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_ALIAS],
});
if (options?.onSuccess) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
options.onSuccess as unknown as (
data: void,
variables: DeleteAliasByIdParams,
context: unknown,
) => void
)(data, variables, context);
}
},
onError: (error, variables, context) => {
if (options?.onError) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
options.onError as unknown as (
error: APIError,
variables: DeleteAliasByIdParams,
context: unknown,
) => void
)(error, variables, context);
}
},
});
};
@@ -0,0 +1,135 @@
import { Button, Input, Tooltip } from '@openfun/cunningham-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useCunninghamTheme } from '@/cunningham';
import { ModalCreateAlias } from '@/features/mail-domains/aliases/components';
import { AliasesListView } from '@/features/mail-domains/aliases/components/panel';
import { MailDomain } from '../../domains/types';
export function AliasesView({ mailDomain }: { mailDomain: MailDomain }) {
const [searchValue, setSearchValue] = useState('');
const [isCreateAliasFormVisible, setIsCreateAliasFormVisible] =
useState(false);
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const colors = colorsTokens();
const canCreateAlias = mailDomain.status === 'enabled';
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setSearchValue(event.target.value);
};
const clearInput = () => {
setSearchValue('');
};
const openModal = () => {
setIsCreateAliasFormVisible(true);
};
return (
<>
<div aria-label="Aliases panel" className="container">
<h3 style={{ fontWeight: 700, fontSize: '18px', marginBottom: 'base' }}>
{t('Aliases')}
</h3>
<div
className="sm:block md:flex"
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px',
gap: '1em',
}}
>
<div
style={{ width: 'calc(100% - 245px)' }}
className="c__input__wrapper__mobile"
>
<Input
style={{ width: '100%' }}
label={t('Search for an alias')}
icon={<span className="material-icons">search</span>}
rightIcon={
searchValue && (
<span
className="material-icons"
onClick={clearInput}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
clearInput();
}
}}
role="button"
tabIndex={0}
style={{ cursor: 'pointer' }}
>
close
</span>
)
}
value={searchValue}
onChange={handleInputChange}
/>
</div>
<div
className="hidden md:flex"
style={{
background: colors['greyscale-200'],
height: '32px',
width: '1px',
}}
></div>
<div
className="block md:hidden"
style={{ marginBottom: '10px' }}
></div>
<div>
{mailDomain?.abilities.post ? (
<Button
data-testid="button-new-alias"
aria-label={t('Create an alias in {{name}} domain', {
name: mailDomain?.name,
})}
disabled={!canCreateAlias}
onClick={() => setIsCreateAliasFormVisible(true)}
>
{t('New alias')}
</Button>
) : (
<Tooltip content={t("You don't have the correct access right")}>
<div>
<Button
data-testid="button-new-alias"
onClick={openModal}
disabled={!isCreateAliasFormVisible}
>
{t('New alias')}
</Button>
</div>
</Tooltip>
)}
</div>
</div>
<AliasesListView mailDomain={mailDomain} querySearch={searchValue} />
{isCreateAliasFormVisible && mailDomain ? (
<ModalCreateAlias
mailDomain={mailDomain}
closeModal={() => setIsCreateAliasFormVisible(false)}
/>
) : null}
</div>
</>
);
}
@@ -0,0 +1,438 @@
import { standardSchemaResolver } from '@hookform/resolvers/standard-schema';
import {
Button,
Loader,
ModalSize,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import React, { useState } from 'react';
import { Controller, FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { parseAPIError } from '@/api/parseAPIError';
import {
Box,
HorizontalSeparator,
Icon,
Input,
Text,
TextErrors,
} from '@/components';
import { CustomModal } from '@/components/modal/CustomModal';
import { MailDomain } from '../../domains/types';
import { useCreateAlias } from '../api';
const FORM_ID = 'form-create-alias';
export const ModalCreateAlias = ({
mailDomain,
closeModal,
}: {
mailDomain: MailDomain;
closeModal: () => void;
}) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const [errorCauses, setErrorCauses] = useState<string[]>([]);
const [step] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [destinations, setDestinations] = useState<string[]>([]);
const [newDestination, setNewDestination] = useState('');
const [destinationError, setDestinationError] = useState<string | null>(null);
type AliasFormData = {
local_part: string;
};
const createAliasValidationSchema: z.ZodType<AliasFormData> = z.object({
local_part: z
.string()
.regex(/^((?!@|\s)([a-zA-Z0-9.\-]))*$/, t('Invalid format'))
.min(1, t('You must have minimum 1 character')),
});
const methods = useForm<AliasFormData>({
resolver: standardSchemaResolver(createAliasValidationSchema),
defaultValues: {
local_part: '',
},
mode: 'onChange',
});
const addDestination = () => {
const trimmed = newDestination.trim();
if (!trimmed) {
setDestinationError(t('Please enter an email address'));
return;
}
// Validation email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(trimmed)) {
setDestinationError(t('Please enter a valid email address'));
return;
}
// Vérifier si déjà présent
if (destinations.includes(trimmed)) {
setDestinationError(t('This email address is already in the list'));
return;
}
setDestinations([...destinations, trimmed]);
setNewDestination('');
setDestinationError(null);
};
const removeDestination = (index: number) => {
setDestinations(destinations.filter((_, i) => i !== index));
};
const handleDestinationKeyPress = (
event: React.KeyboardEvent<HTMLInputElement>,
) => {
if (event.key === 'Enter') {
event.preventDefault();
addDestination();
}
};
const { mutate: createAlias } = useCreateAlias({
mailDomainSlug: mailDomain.slug,
});
const onSubmitCallback = async (event: React.FormEvent) => {
event.preventDefault();
const isValid = await methods.trigger();
if (!isValid) {
return;
}
if (destinations.length === 0) {
toast(t('Please add at least one destination email'), VariantType.ERROR, {
duration: 4000,
});
return;
}
const data = methods.getValues();
setIsSubmitting(true);
setErrorCauses([]);
let successCount = 0;
let errorCount = 0;
const allErrors: string[] = [];
for (const destination of destinations) {
try {
await new Promise<void>((resolve, reject) => {
createAlias(
{
local_part: data.local_part,
destination: destination.trim(),
mailDomainSlug: mailDomain.slug,
},
{
onSuccess: () => {
successCount++;
resolve();
},
onError: (error) => {
errorCount++;
const causes =
parseAPIError({
error,
errorParams: [
[
['Local part ".*" already used by a mailbox.'],
t('This email prefix is already used by a mailbox.'),
undefined,
],
[
['Invalid format'],
t('Invalid format for the email prefix.'),
undefined,
],
],
serverErrorParams: [
t(
'The domain must be enabled to create aliases. Please check the domain status.',
),
undefined,
],
}) || [];
if (causes.length > 0) {
allErrors.push(...causes);
}
reject(error);
},
},
);
});
} catch {
// Erreur déjà gérée dans onError
}
}
setIsSubmitting(false);
// Afficher les résultats
if (errorCount > 0) {
setErrorCauses(allErrors);
}
if (successCount === destinations.length) {
toast(
t('All {{count}} alias(es) created successfully!', {
count: successCount,
}),
VariantType.SUCCESS,
{ duration: 4000 },
);
closeModal();
} else if (successCount > 0) {
toast(
t('{{success}} alias(es) created, {{errors}} failed', {
success: successCount,
errors: errorCount,
}),
VariantType.WARNING,
{ duration: 5000 },
);
} else {
toast(t('Failed to create aliases'), VariantType.ERROR, {
duration: 4000,
});
}
};
const steps = [
{
title: t('New alias'),
content: (
<FormProvider {...methods}>
{!!errorCauses.length && <TextErrors causes={errorCauses} />}
<form
id={FORM_ID}
onSubmit={(e) => {
void onSubmitCallback(e);
}}
>
<Box $padding={{ top: 'sm', horizontal: 'md' }} $gap="4px">
<Text $size="md" $weight="bold">
{t('Alias configuration')}
</Text>
<Text $theme="greyscale" $variation="600">
{t(
'An alias allows you to redirect emails to one or more addresses.',
)}
</Text>
</Box>
<Box
$padding="md"
style={{
position: 'relative',
alignItems: 'end',
gap: '20px',
flexDirection: 'row',
alignContent: 'flex-end',
}}
>
<Controller
name="local_part"
control={methods.control}
render={({ field }) => (
<Box $align="center">
<Input
{...field}
label={t('Name of the alias')}
required
placeholder={t('contact')}
/>
</Box>
)}
/>
<Box
style={{
display: 'flex',
position: 'absolute',
top: '58px',
left: '210px',
}}
>
<Text className="mb-8" $weight="500">
@{mailDomain.name}
</Text>
</Box>
</Box>
<HorizontalSeparator $withPadding={true} />
<Box $padding={{ horizontal: 'md' }}>
<Box $margin={{ top: 'base', bottom: 'base' }} $gap="12px">
<Text $size="sm" $weight="500">
{t('Destination email addresses')}
</Text>
<Box $gap="4px">
<Box $direction="row" $gap="8px" $align="end">
<Box style={{ flex: 1 }}>
<Input
value={newDestination}
onChange={(e) => {
setNewDestination(e.target.value);
setDestinationError(null);
}}
onKeyPress={handleDestinationKeyPress}
label={t('Add destination email')}
placeholder=""
/>
</Box>
<Button
type="button"
onClick={addDestination}
disabled={!newDestination.trim()}
>
{t('Add destination')}
</Button>
</Box>
{destinationError && (
<Text $theme="warning" $size="sm">
{destinationError}
</Text>
)}
</Box>
{/* Destinations Array */}
{destinations.length > 0 && (
<Box
$margin={{ top: 'md' }}
style={{
border:
'1px solid var(--c--contextuals--border--surface--primary)',
borderRadius: '4px',
overflow: 'hidden',
}}
>
<table
style={{ width: '100%', borderCollapse: 'collapse' }}
>
<thead>
<tr
style={{
paddingBottom: '12px',
borderBottom:
'1px solid var(--c--contextuals--border--surface--primary)',
}}
>
<th
style={{
textAlign: 'left',
fontWeight: 500,
fontSize: '14px',
}}
>
{t('Email address')}
</th>
<th
style={{
paddingBottom: '12px',
textAlign: 'right',
width: '80px',
}}
>
{t('Actions')}
</th>
</tr>
</thead>
<tbody>
{destinations.map((destination, index) => (
<tr
key={index}
style={{
paddingBottom: '12px',
borderBottom:
index < destinations.length - 1
? '1px solid var(--c--contextuals--border--surface--primary)'
: 'none',
}}
>
<td>
<Text $size="sm">{destination}</Text>
</td>
<td style={{ textAlign: 'right' }}>
<Button
type="button"
color="tertiary"
onClick={() => removeDestination(index)}
aria-label={t('Remove destination')}
icon={<Icon iconName="delete" />}
/>
</td>
</tr>
))}
</tbody>
</table>
</Box>
)}
</Box>
</Box>
</form>
</FormProvider>
),
leftAction: (
<Button color="secondary" onClick={closeModal}>
{t('Cancel')}
</Button>
),
rightAction: (
<Button
type="submit"
form={FORM_ID}
disabled={
!methods.formState.isValid ||
destinations.length === 0 ||
isSubmitting
}
>
{isSubmitting ? t('Creating...') : t('Create alias')}
</Button>
),
},
];
return (
<div id="modal-new-alias">
<CustomModal
isOpen
hideCloseButton
step={step}
totalSteps={steps.length}
leftActions={steps[step].leftAction}
rightActions={steps[step].rightAction}
size={ModalSize.MEDIUM}
title={steps[step].title}
onClose={closeModal}
closeOnEsc
closeOnClickOutside
>
{steps[step].content}
{isSubmitting && (
<Box $align="center" $padding="md">
<Loader />
<Text $theme="greyscale" $variation="600" $margin={{ top: 'sm' }}>
{t('Creating alias...')}
</Text>
</Box>
)}
</CustomModal>
</div>
);
};
@@ -0,0 +1,578 @@
import {
Button,
Loader,
ModalSize,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { parseAPIError } from '@/api/parseAPIError';
import {
Box,
HorizontalSeparator,
Icon,
Input,
Text,
TextErrors,
} from '@/components';
import { Modal } from '@/components/Modal';
import { CustomModal } from '@/components/modal/CustomModal';
import { MailDomain } from '../../domains/types';
import { useCreateAlias } from '../api/useCreateAlias';
import { useDeleteAlias } from '../api/useDeleteAlias';
import { useDeleteAliasById } from '../api/useDeleteAliasById';
import { AliasGroup } from '../types';
const FORM_ID = 'form-edit-alias';
export const ModalEditAlias = ({
mailDomain,
aliasGroup,
closeModal,
}: {
mailDomain: MailDomain;
aliasGroup: AliasGroup;
closeModal: () => void;
}) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const [errorCauses, setErrorCauses] = useState<string[]>([]);
const [step] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [destinations, setDestinations] = useState<string[]>([]);
const [newDestination, setNewDestination] = useState('');
const [destinationError, setDestinationError] = useState<string | undefined>(
undefined,
);
const [confirmModal, setConfirmModal] = useState<{
isOpen: boolean;
title: string;
message: string;
onConfirm: () => void;
}>({
isOpen: false,
title: '',
message: '',
onConfirm: () => {},
});
useEffect(() => {
setDestinations([...aliasGroup.destinations]);
}, [aliasGroup]);
const addDestination = async () => {
const trimmed = newDestination.trim();
if (!trimmed) {
setDestinationError(t('Please enter an email address'));
return;
}
// Valid email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(trimmed)) {
setDestinationError(t('Please enter a valid email address'));
return;
}
// Email already in list
if (destinations.includes(trimmed)) {
setDestinationError(t('This email address is already in the list'));
return;
}
// Check domain enabled
if (mailDomain.status !== 'enabled') {
setDestinationError(
t(
'The domain must be enabled to add destinations. Current status: {{status}}',
{
status: mailDomain.status,
},
),
);
return;
}
setIsSubmitting(true);
setDestinationError(undefined);
try {
await new Promise<void>((resolve, reject) => {
createAlias(
{
local_part: aliasGroup.local_part,
destination: trimmed,
mailDomainSlug: mailDomain.slug,
},
{
onSuccess: () => {
toast(t('Destination added successfully'), VariantType.SUCCESS, {
duration: 4000,
});
setDestinations([...destinations, trimmed]);
setNewDestination('');
resolve();
},
onError: (error) => {
const causes =
parseAPIError({
error,
errorParams: [
[
['Local part ".*" already used by a mailbox.'],
t('This email prefix is already used by a mailbox.'),
undefined,
],
[
['Invalid format'],
t('Invalid format for the email prefix.'),
undefined,
],
],
serverErrorParams: [
t(
'The domain must be enabled to add destinations. Please check the domain status.',
),
undefined,
],
}) || [];
if (causes.length > 0) {
setDestinationError(causes[0]);
} else {
setDestinationError(
t('Failed to add destination. Please try again.'),
);
}
reject(error);
},
},
);
});
} catch {
} finally {
setIsSubmitting(false);
}
};
const removeDestination = (destination: string) => {
setConfirmModal({
isOpen: true,
title: t('Remove destination'),
message: t(
'Are you sure you want to remove {{destination}} from this alias?',
{ destination },
),
onConfirm: () => {
setConfirmModal({ ...confirmModal, isOpen: false });
void handleRemoveDestination(destination);
},
});
};
const handleRemoveDestination = async (destination: string) => {
setIsSubmitting(true);
const aliasId = aliasGroup.destinationIds[destination];
if (!aliasId) {
toast(
t('Failed to find alias ID for this destination'),
VariantType.ERROR,
{
duration: 4000,
},
);
setIsSubmitting(false);
return;
}
try {
await new Promise<void>((resolve, reject) => {
deleteAliasById(
{
mailDomainSlug: mailDomain.slug,
aliasId,
},
{
onSuccess: () => {
toast(
t('Destination removed successfully'),
VariantType.SUCCESS,
{
duration: 4000,
},
);
setDestinations(destinations.filter((d) => d !== destination));
resolve();
closeModal();
},
onError: (error) => {
const causes =
parseAPIError({
error,
errorParams: [],
serverErrorParams: [
t(
'An error occurred while removing the destination. Please try again.',
),
undefined,
],
}) || [];
if (causes.length > 0) {
setDestinationError(causes[0]);
} else {
toast(t('Failed to remove destination'), VariantType.ERROR, {
duration: 4000,
});
}
reject(error);
},
},
);
});
} catch {
} finally {
setIsSubmitting(false);
}
};
const handleRemoveAllDestinations = () => {
setConfirmModal({
isOpen: true,
title: t('Delete this alias'),
message: t(
'Are you sure you want to remove all destinations from this alias? This action cannot be undone.',
),
onConfirm: () => {
setConfirmModal({ ...confirmModal, isOpen: false });
void removeAllDestinations();
},
});
};
const removeAllDestinations = async () => {
if (destinations.length === 0) {
return;
}
setIsSubmitting(true);
setErrorCauses([]);
try {
await new Promise<void>((resolve, reject) => {
deleteAlias(
{
mailDomainSlug: mailDomain.slug,
localPart: aliasGroup.local_part,
},
{
onSuccess: () => {
toast(t('Alias deleted successfully'), VariantType.SUCCESS, {
duration: 4000,
});
setDestinations([]);
resolve();
closeModal();
},
onError: (error) => {
const causes =
parseAPIError({
error,
errorParams: [],
serverErrorParams: [
t(
'An error occurred while deleting the alias. Please try again.',
),
undefined,
],
}) || [];
if (causes.length > 0) {
setErrorCauses(causes);
} else {
toast(t('Failed to delete alias'), VariantType.ERROR, {
duration: 4000,
});
}
reject(error);
},
},
);
});
} catch {
} finally {
setIsSubmitting(false);
}
};
const handleDestinationKeyPress = (
event: React.KeyboardEvent<HTMLInputElement>,
) => {
if (event.key === 'Enter') {
event.preventDefault();
void addDestination();
}
};
const { mutate: createAlias } = useCreateAlias({
mailDomainSlug: mailDomain.slug,
});
const { mutate: deleteAliasById } = useDeleteAliasById();
const { mutate: deleteAlias } = useDeleteAlias();
const steps = [
{
title: t('Manage alias'),
content: (
<>
{!!errorCauses.length && <TextErrors causes={errorCauses} />}
<form
id={FORM_ID}
onSubmit={(e) => {
e.preventDefault();
void addDestination();
}}
>
<Box $padding={{ top: 'sm', horizontal: 'md' }} $gap="4px">
<Text $size="md" $weight="bold">
{t('Alias configuration')}
</Text>
<Text $theme="greyscale" $variation="600">
{t('Manage the destination email addresses for this alias.')}
</Text>
</Box>
<Box
$padding="md"
style={{
position: 'relative',
alignItems: 'end',
gap: '20px',
flexDirection: 'row',
alignContent: 'flex-end',
}}
>
<Box>
<Input
value={aliasGroup.local_part}
label={t('Name of the alias')}
disabled
readOnly
/>
</Box>
<Box
style={{
display: 'flex',
position: 'absolute',
top: '58px',
left: '210px',
}}
>
<Text className="mb-8" $weight="500">
@{mailDomain.name}
</Text>
</Box>
</Box>
<HorizontalSeparator $withPadding={true} />
<Box $padding={{ horizontal: 'md' }}>
<Box $margin={{ top: 'base', bottom: 'base' }} $gap="12px">
<Text $size="sm" $weight="500">
{t('Destination email addresses')}
</Text>
<Box $gap="4px">
<Box $direction="row" $gap="8px" $align="end">
<Box style={{ flex: 1 }}>
<Input
value={newDestination}
onChange={(e) => {
setNewDestination(e.target.value);
setDestinationError(undefined);
}}
onKeyPress={handleDestinationKeyPress}
error={destinationError}
label={t('Add destination email')}
placeholder={t('john.appleseed@example.fr')}
/>
</Box>
<Button
type="submit"
form={FORM_ID}
disabled={!newDestination.trim() || isSubmitting}
>
{isSubmitting ? t('Adding...') : t('Add')}
</Button>
</Box>
</Box>
{/* Tableau des destinations */}
{destinations.length > 0 && (
<Box
$margin={{ top: 'md' }}
$css={`
table tbody tr {
transition: background-color 0.2s ease;
cursor: pointer;
}
table tbody tr:hover {
background-color: rgba(0, 0, 0, 0.04);
}
`}
>
<table
style={{ width: '100%', borderCollapse: 'collapse' }}
>
<thead>
<tr
style={{
paddingBottom: '12px',
borderBottom:
'1px solid var(--c--contextuals--border--surface--primary)',
}}
>
<th
style={{
textAlign: 'left',
fontWeight: 500,
fontSize: '14px',
}}
>
{t('Email address')}
</th>
<th
style={{
paddingBottom: '12px',
textAlign: 'right',
width: '80px',
}}
>
{t('Actions')}
</th>
</tr>
</thead>
<tbody>
{destinations.map((destination, index) => (
<tr
key={index}
style={{
paddingBottom: '12px',
marginBottom: '12px',
}}
>
<td style={{ paddingLeft: '12px' }}>
<Text $size="sm">{destination}</Text>
</td>
<td style={{ textAlign: 'right' }}>
<Button
type="button"
color="tertiary"
onClick={() => removeDestination(destination)}
aria-label={t('Remove destination')}
icon={<Icon iconName="delete" />}
disabled={isSubmitting}
/>
</td>
</tr>
))}
</tbody>
</table>
</Box>
)}
</Box>
</Box>
</form>
</>
),
leftAction: (
<Button color="secondary" onClick={closeModal}>
{t('Close')}
</Button>
),
rightAction: (
<Box $direction="row" $gap="6px">
{destinations.length > 0 && (
<Button
type="button"
color="danger"
onClick={handleRemoveAllDestinations}
disabled={isSubmitting}
icon={
<Icon iconName="delete" $theme="greyscale" $variation="000" />
}
>
{t('Delete this alias')}
</Button>
)}
</Box>
),
},
];
return (
<div id="modal-edit-alias">
<CustomModal
isOpen
hideCloseButton
step={step}
totalSteps={steps.length}
leftActions={steps[step].leftAction}
rightActions={steps[step].rightAction}
size={ModalSize.MEDIUM}
title={steps[step].title}
onClose={closeModal}
closeOnEsc
closeOnClickOutside
>
{steps[step].content}
{isSubmitting && (
<Box $align="center" $padding="md">
<Loader />
<Text $theme="greyscale" $variation="600" $margin={{ top: 'sm' }}>
{t('Updating alias...')}
</Text>
</Box>
)}
</CustomModal>
{/* Confirmation Modal */}
<Modal
isOpen={confirmModal.isOpen}
closeOnClickOutside
hideCloseButton
leftActions={
<Button
color="secondary"
fullWidth
onClick={() => setConfirmModal({ ...confirmModal, isOpen: false })}
>
{t('Cancel')}
</Button>
}
rightActions={
<Button
color="danger"
fullWidth
onClick={confirmModal.onConfirm}
disabled={isSubmitting}
>
{t('Confirm')}
</Button>
}
size={ModalSize.MEDIUM}
title={confirmModal.title}
onClose={() => setConfirmModal({ ...confirmModal, isOpen: false })}
>
<Box $padding="md">
<Text>{confirmModal.message}</Text>
</Box>
</Modal>
</div>
);
};
@@ -0,0 +1,3 @@
export * from './AliasesView';
export * from './ModalCreateAlias';
export * from './ModalEditAlias';
@@ -0,0 +1,222 @@
import { Button, DataGrid, SortModel } from '@openfun/cunningham-react';
import type { InfiniteData } from '@tanstack/react-query';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text, TextErrors } from '@/components';
import { useAuthStore } from '@/core/auth';
import { Alias, AliasGroup } from '@/features/mail-domains/aliases/types';
import { MailDomain } from '@/features/mail-domains/domains';
import { useAliasesInfinite } from '../../api/useAliasesInfinite';
import { ModalEditAlias } from '../ModalEditAlias';
type MailDomainAliasesResponse = {
count: number;
next: string | null;
previous: string | null;
results: Alias[];
};
interface AliasesListViewProps {
mailDomain: MailDomain;
querySearch: string;
}
type SortModelItem = {
field: string;
sort: 'asc' | 'desc' | null;
};
function formatSortModel(sortModel: SortModelItem) {
return sortModel.sort === 'desc' ? `-${sortModel.field}` : sortModel.field;
}
export function AliasesListView({
mailDomain,
querySearch,
}: AliasesListViewProps) {
const { t } = useTranslation();
const { userData } = useAuthStore();
const [editingAliasGroup, setEditingAliasGroup] = useState<AliasGroup | null>(
null,
);
const [sortModel] = useState<SortModel>([]);
const ordering = sortModel.length ? formatSortModel(sortModel[0]) : undefined;
const {
data,
isLoading,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useAliasesInfinite({
mailDomainSlug: mailDomain.slug,
ordering,
}) as {
data: InfiniteData<MailDomainAliasesResponse, number> | undefined;
isLoading: boolean;
error: { cause?: string[] };
fetchNextPage: () => void;
hasNextPage: boolean | undefined;
isFetchingNextPage: boolean;
};
const aliasGroups: AliasGroup[] = useMemo(() => {
if (!mailDomain || !data?.pages?.length) {
return [];
}
const grouped = new Map<string, AliasGroup>();
data.pages.forEach((page) => {
page.results.forEach((alias: Alias) => {
const email = `${alias.local_part}@${mailDomain.name}`;
const existing = grouped.get(alias.local_part);
if (existing) {
if (!existing.destinations.includes(alias.destination)) {
existing.destinations.push(alias.destination);
existing.destinationIds[alias.destination] = alias.id;
existing.count_destinations = existing.destinations.length;
}
} else {
const destinationIds: Record<string, string> = {};
destinationIds[alias.destination] = alias.id;
grouped.set(alias.local_part, {
id: alias.local_part,
email,
local_part: alias.local_part,
destinations: [alias.destination],
destinationIds,
count_destinations: 1,
});
}
});
});
return Array.from(grouped.values());
}, [data, mailDomain]);
const filteredAliases = useMemo(() => {
if (!querySearch) {
return aliasGroups;
}
const lowerCaseSearch = querySearch.toLowerCase();
return aliasGroups.filter(
(alias) =>
alias.email.toLowerCase().includes(lowerCaseSearch) ||
alias.destinations.some((dest) =>
dest.toLowerCase().includes(lowerCaseSearch),
),
);
}, [querySearch, aliasGroups]);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!hasNextPage) {
return;
}
const ref = loadMoreRef.current;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ threshold: 1 },
);
if (ref) {
observer.observe(ref);
}
return () => {
if (ref) {
observer.unobserve(ref);
}
};
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
return (
<div>
{error && <TextErrors causes={error.cause ?? []} />}
{!filteredAliases.length && (
<Text $align="center" $size="small" $padding={{ top: 'base' }}>
{t('No alias was created with this mail domain.')}
</Text>
)}
{filteredAliases && filteredAliases.length ? (
<>
<DataGrid
aria-label="aliaslist"
rows={filteredAliases}
columns={[
{
field: 'email',
headerName: `${t('Alias')}${filteredAliases.length}`,
renderCell: ({ row }) => (
<Text $weight="400" $theme="greyscale">
{row.email}
</Text>
),
},
{
field: 'destinations',
headerName: t('Destinations'),
enableSorting: false,
renderCell: ({ row }) => (
<Text $weight="500" $theme="greyscale">
{row.count_destinations} destination
{row.count_destinations > 1 ? 's' : ''}
</Text>
),
},
{
id: 'actions',
renderCell: ({ row }) => {
// Check if user can edit
const isOwnerOrAdmin =
mailDomain.abilities?.patch || mailDomain.abilities?.put;
const isAliasDestination = row.destinations.some(
(dest) => dest === userData?.email,
);
const canEdit = isOwnerOrAdmin || isAliasDestination;
return (
<Box $direction="row" $gap="sm" $align="center">
{canEdit && (
<Button
color="tertiary"
onClick={() => setEditingAliasGroup(row)}
style={{
fontWeight: '500',
fontSize: '16px',
}}
>
{t('Manage')}
</Button>
)}
</Box>
);
},
},
]}
isLoading={isLoading}
/>
{isFetchingNextPage && <div>{t('Loading more...')}</div>}
</>
) : null}
<div ref={loadMoreRef} />
{editingAliasGroup && (
<ModalEditAlias
mailDomain={mailDomain}
aliasGroup={editingAliasGroup}
closeModal={() => setEditingAliasGroup(null)}
/>
)}
</div>
);
}
@@ -0,0 +1 @@
export * from './AliasesListView';
@@ -0,0 +1,3 @@
export * from './api';
export * from './components';
export * from './types';
@@ -0,0 +1,24 @@
import { UUID } from 'crypto';
export interface Alias {
id: UUID;
local_part: string;
destination: string;
}
export interface ViewAlias {
id: string;
email: string;
local_part: string;
destination: string;
alias: Alias;
}
export interface AliasGroup {
id: string;
email: string;
local_part: string;
destinations: string[];
destinationIds: Record<string, string>;
count_destinations: number;
}
@@ -3,8 +3,10 @@ import { useRouter } from 'next/navigation';
import * as React from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Tag, Text } from '@/components';
import { Box, CustomTabs, Tag, Text } from '@/components';
import { useCunninghamTheme } from '@/cunningham';
import { AliasesView } from '@/features/mail-domains/aliases';
import { useAliases } from '@/features/mail-domains/aliases/api/useAliases';
import MailDomainsLogo from '@/features/mail-domains/assets/mail-domains-logo.svg';
import {
MailDomain,
@@ -13,6 +15,7 @@ import {
Role,
} from '@/features/mail-domains/domains';
import { MailBoxesView } from '@/features/mail-domains/mailboxes';
import { useMailboxes } from '@/features/mail-domains/mailboxes/api/useMailboxes';
type Props = {
mailDomain: MailDomain;
@@ -30,6 +33,18 @@ export const MailDomainView = ({
const router = useRouter();
const [showModal, setShowModal] = React.useState(false);
const { data: mailboxesData } = useMailboxes({
mailDomainSlug: mailDomain.slug,
page: 1,
});
const { data: aliasesData } = useAliases({
mailDomainSlug: mailDomain.slug,
page: 1,
});
const countMailboxes = mailboxesData?.count ?? 0;
const countAliases = aliasesData?.count ?? 0;
const handleShowModal = () => {
setShowModal(true);
};
@@ -110,15 +125,27 @@ export const MailDomainView = ({
$padding={{ horizontal: 'md' }}
$margin={{ top: 'md' }}
$background="white"
$align="center"
$gap="8px"
$radius="4px"
$direction="row"
$css={`
border: 1px solid ${colorsTokens()['greyscale-200']};
`}
>
<MailBoxesView mailDomain={mailDomain} />
<CustomTabs
tabs={[
{
id: 'mailboxes',
label: t('Email addresses') + ` (${countMailboxes})`,
iconName: 'mail',
content: <MailBoxesView mailDomain={mailDomain} />,
},
{
id: 'aliases',
label: t('Aliases') + ` (${countAliases})`,
iconName: 'forward_to_inbox',
content: <AliasesView mailDomain={mailDomain} />,
},
]}
/>
</Box>
</Box>
</>
@@ -27,7 +27,7 @@ export const ModalAddMailDomain = ({
const addMailDomainValidationSchema = z.object({
name: z.string().min(1, t('Example: saint-laurent.fr')),
supportEmail: z.email(t('Please enter a valid email address')),
supportEmail: z.string().email(t('Please enter a valid email address')),
});
const methods = useForm<{ name: string; supportEmail: string }>({
@@ -46,7 +46,8 @@ export const useCreateMailbox = (options: UseCreateMailboxParams) => {
const queryClient = useQueryClient();
return useMutation<void, APIError, CreateMailboxParams>({
mutationFn: createMailbox,
onSuccess: (data, variables, onMutateResult, context) => {
...options,
onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({
queryKey: [
KEY_LIST_MAILBOX,
@@ -54,12 +55,26 @@ export const useCreateMailbox = (options: UseCreateMailboxParams) => {
],
});
if (options?.onSuccess) {
options.onSuccess(data, variables, onMutateResult, context);
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
options.onSuccess as unknown as (
data: void,
variables: CreateMailboxParams,
context: unknown,
) => void
)(data, variables, context);
}
},
onError: (error, variables, onMutateResult, context) => {
onError: (error, variables, context) => {
if (options?.onError) {
options.onError(error, variables, onMutateResult, context);
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
options.onError as unknown as (
error: APIError,
variables: CreateMailboxParams,
context: unknown,
) => void
)(error, variables, context);
}
},
});
@@ -49,7 +49,8 @@ export const useUpdateMailbox = (options: UseUpdateMailboxParams) => {
return useMutation<void, APIError, UpdateMailboxParams>({
mutationFn: (data) =>
updateMailbox({ ...data, mailboxId: options.mailboxId }),
onSuccess: (data, variables, onMutateResult, context) => {
...options,
onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({
queryKey: [
KEY_LIST_MAILBOX,
@@ -57,12 +58,26 @@ export const useUpdateMailbox = (options: UseUpdateMailboxParams) => {
],
});
if (options?.onSuccess) {
options.onSuccess(data, variables, onMutateResult, context);
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
options.onSuccess as unknown as (
data: void,
variables: UpdateMailboxParams,
context: unknown,
) => void
)(data, variables, context);
}
},
onError: (error, variables, onMutateResult, context) => {
onError: (error, variables, context) => {
if (options?.onError) {
options.onError(error, variables, onMutateResult, context);
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
options.onError as unknown as (
error: APIError,
variables: UpdateMailboxParams,
context: unknown,
) => void
)(error, variables, context);
}
},
});
@@ -41,7 +41,7 @@ export const useDeleteTeamAccess = (options?: UseDeleteTeamAccessOptions) => {
return useMutation<void, APIError, DeleteTeamAccessProps>({
mutationFn: deleteTeamAccess,
...options,
onSuccess: (data, variables, onMutateResult, context) => {
onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_TEAM_ACCESSES],
});
@@ -52,12 +52,26 @@ export const useDeleteTeamAccess = (options?: UseDeleteTeamAccessOptions) => {
queryKey: [KEY_LIST_TEAM],
});
if (options?.onSuccess) {
options.onSuccess(data, variables, onMutateResult, context);
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
options.onSuccess as unknown as (
data: void,
variables: DeleteTeamAccessProps,
context: unknown,
) => void
)(data, variables, context);
}
},
onError: (error, variables, onMutateResult, context) => {
onError: (error, variables, context) => {
if (options?.onError) {
options.onError(error, variables, onMutateResult, context);
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
options.onError as unknown as (
error: APIError,
variables: DeleteTeamAccessProps,
context: unknown,
) => void
)(error, variables, context);
}
},
});
@@ -49,7 +49,7 @@ export const useUpdateTeamAccess = (options?: UseUpdateTeamAccessOptions) => {
return useMutation<Access, APIError, UpdateTeamAccessProps>({
mutationFn: updateTeamAccess,
...options,
onSuccess: (data, variables, onMutateResult, context) => {
onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_TEAM_ACCESSES],
});
@@ -57,12 +57,26 @@ export const useUpdateTeamAccess = (options?: UseUpdateTeamAccessOptions) => {
queryKey: [KEY_TEAM],
});
if (options?.onSuccess) {
options.onSuccess(data, variables, onMutateResult, context);
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
options.onSuccess as unknown as (
data: Access,
variables: UpdateTeamAccessProps,
context: unknown,
) => void
)(data, variables, context);
}
},
onError: (error, variables, onMutateResult, context) => {
onError: (error, variables, context) => {
if (options?.onError) {
options.onError(error, variables, onMutateResult, context);
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
options.onError as unknown as (
error: APIError,
variables: UpdateTeamAccessProps,
context: unknown,
) => void
)(error, variables, context);
}
},
});
@@ -34,17 +34,31 @@ export const useRemoveTeam = (options?: UseRemoveTeamOptions) => {
return useMutation<void, APIError, RemoveTeamProps>({
mutationFn: removeTeam,
...options,
onSuccess: (data, variables, onMutateResult, context) => {
onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_TEAM],
});
if (options?.onSuccess) {
options.onSuccess(data, variables, onMutateResult, context);
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
options.onSuccess as unknown as (
data: void,
variables: RemoveTeamProps,
context: unknown,
) => void
)(data, variables, context);
}
},
onError: (error, variables, onMutateResult, context) => {
onError: (error, variables, context) => {
if (options?.onError) {
options.onError(error, variables, onMutateResult, context);
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
options.onError as unknown as (
error: APIError,
variables: RemoveTeamProps,
context: unknown,
) => void
)(error, variables, context);
}
},
});
@@ -1,27 +0,0 @@
import { expect, test } from '@playwright/test';
import { keyCloakSignIn } from './common';
test.beforeEach(async ({ page, browserName }) => {
await page.goto('/');
await keyCloakSignIn(page, browserName, 'marie');
});
test.describe('When a commune, domain is created on first login via ProConnect', () => {
test('it checks the domain has been created and is operational', async ({
page,
}) => {
const menu = page.locator('menu').first();
await menu.getByRole('button', { name: 'Mail Domains button' }).click();
await page.waitForURL('http://localhost:3000/mail-domains/**');
await expect(
page.getByRole('heading', {
name: 'Domains of the organization',
exact: true,
}),
).toBeVisible();
await expect(page.getByText('merlaut.test.collectivite.fr')).toHaveCount(1);
await expect(page.getByText('No domains exist.')).toHaveCount(0);
});
});
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "app-e2e",
"version": "1.21.0",
"version": "1.22.2",
"private": true,
"scripts": {
"lint": "eslint . --ext .ts",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "people",
"version": "1.21.0",
"version": "1.22.2",
"private": true,
"workspaces": {
"packages": [
@@ -1,6 +1,6 @@
{
"name": "eslint-config-people",
"version": "1.21.0",
"version": "1.22.2",
"license": "MIT",
"scripts": {
"lint": "eslint --ext .js ."
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "packages-i18n",
"version": "1.21.0",
"version": "1.22.2",
"private": true,
"scripts": {
"extract-translation": "yarn extract-translation:desk",
+15 -10
View File
@@ -2307,10 +2307,10 @@
"@emnapi/runtime" "^1.4.0"
"@tybys/wasm-util" "^0.9.0"
"@next/env@15.4.8":
version "15.4.8"
resolved "https://registry.yarnpkg.com/@next/env/-/env-15.4.8.tgz#f41741d07651958bccb31fb685da0303a9ef1373"
integrity sha512-LydLa2MDI1NMrOFSkO54mTc8iIHSttj6R6dthITky9ylXV2gCGi0bHQjVCtLGRshdRPjyh2kXbxJukDtBWQZtQ==
"@next/env@15.4.10":
version "15.4.10"
resolved "https://registry.yarnpkg.com/@next/env/-/env-15.4.10.tgz#a794b738d043d9e98ea435bd45254899f7f77714"
integrity sha512-knhmoJ0Vv7VRf6pZEPSnciUG1S4bIhWx+qTYBW/AjxEtlzsiNORPk8sFDCEvqLfmKuey56UB9FL1UdHEV3uBrg==
"@next/eslint-plugin-next@15.3.2":
version "15.3.2"
@@ -8170,7 +8170,12 @@ lodash.truncate@^4.4.2:
resolved "https://registry.yarnpkg.com/lodash.truncate/-/lodash.truncate-4.4.2.tgz#5a350da0b1113b837ecfffd5812cbe58d6eae193"
integrity sha512-jttmRe7bRse52OsWIMDLaXxWqRAmtIUccAQ3garviCqJjafXOfNMO0yMfNpdD6zbGaTU0P5Nz7e7gAT6cKmJRw==
lodash@4.17.21, lodash@^4.17.21:
lodash@4.17.23:
version "4.17.23"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.23.tgz#f113b0378386103be4f6893388c73d0bde7f2c5a"
integrity sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==
lodash@^4.17.21:
version "4.17.21"
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.21.tgz#679591c564c3bffaae8454cf0b3df370c3d6911c"
integrity sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==
@@ -8370,12 +8375,12 @@ neo-async@^2.6.2:
resolved "https://registry.yarnpkg.com/neo-async/-/neo-async-2.6.2.tgz#b4aafb93e3aeb2d8174ca53cf163ab7d7308305f"
integrity sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==
next@15.4.8:
version "15.4.8"
resolved "https://registry.yarnpkg.com/next/-/next-15.4.8.tgz#0f20a6cad613dc34547fa6519b2d09005ac370ca"
integrity sha512-jwOXTz/bo0Pvlf20FSb6VXVeWRssA2vbvq9SdrOPEg9x8E1B27C2rQtvriAn600o9hH61kjrVRexEffv3JybuA==
next@15.4.10:
version "15.4.10"
resolved "https://registry.yarnpkg.com/next/-/next-15.4.10.tgz#4ee237d4eb16289f6e16167fbed59d8ada86aa59"
integrity sha512-itVlc79QjpKMFMRhP+kbGKaSG/gZM6RCvwhEbwmCNF06CdDiNaoHcbeg0PqkEa2GOcn8KJ0nnc7+yL7EjoYLHQ==
dependencies:
"@next/env" "15.4.8"
"@next/env" "15.4.10"
"@swc/helpers" "0.5.15"
caniuse-lite "^1.0.30001579"
postcss "8.4.31"