Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f85e66abe | |||
| 6ae195b90c | |||
| be64abb22f | |||
| ca56eb0cac | |||
| 99ca34719f | |||
| 014fb62f95 | |||
| 99433a6722 | |||
| 5ebc88bcff | |||
| a65e61bd96 | |||
| b8beb56135 | |||
| 37e5b5b346 | |||
| 31201fbd59 | |||
| eb0683ffe0 | |||
| 54219d25b8 | |||
| 89e7703d53 | |||
| 7d44e54913 | |||
| 01583ba94f | |||
| 5feee53bdd | |||
| 9c62efc9f8 | |||
| d2ef9e0beb | |||
| bc1cbef168 | |||
| 8ab1b2e2ef | |||
| f498e3b6d2 | |||
| e47573e22f | |||
| c7aab6f5b5 | |||
| 2144ad5da1 | |||
| f00deeebe9 |
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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 && \
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -46,8 +46,6 @@ services:
|
||||
postgresql:
|
||||
condition: service_healthy
|
||||
restart: true
|
||||
dimail:
|
||||
condition: service_started
|
||||
maildev:
|
||||
condition: service_started
|
||||
redis:
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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={
|
||||
|
||||
+21
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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
|
||||
|
||||
+76
@@ -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,
|
||||
|
||||
+4
-20
@@ -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",
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
+4
-8
@@ -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
@@ -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 @@
|
||||
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
|
||||
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
-2
@@ -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(
|
||||
|
||||
+1
-2
@@ -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(
|
||||
|
||||
+28
-5
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
+26
-7
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
+26
-7
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
+438
@@ -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>
|
||||
);
|
||||
};
|
||||
+578
@@ -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';
|
||||
+222
@@ -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;
|
||||
}
|
||||
+32
-5
@@ -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>
|
||||
</>
|
||||
|
||||
+1
-1
@@ -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 }>({
|
||||
|
||||
+19
-4
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
+19
-4
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
+18
-4
@@ -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);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
+18
-4
@@ -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,6 +1,6 @@
|
||||
{
|
||||
"name": "app-e2e",
|
||||
"version": "1.21.0",
|
||||
"version": "1.22.2",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"lint": "eslint . --ext .ts",
|
||||
|
||||
@@ -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,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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user