Compare commits

...

93 Commits

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

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

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

add list view aliases + creation aliases

* (front) add delete alias

add modale to delete aliases

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

remove onMutateResult from mutation callbacks
2026-01-19 17:04:57 +01:00
Marie PUPO JEAMMET 54219d25b8 (tests) update auth-related tests after bumping django-lasuite
update the expected number of queries in authentication-related tests
due to bumping django-lasuite to 0.0.22
2026-01-19 10:09:29 +00:00
renovate[bot] 89e7703d53 ⬆️(dependencies) update python dependencies
keep django < 6.0 for compatibility with django-celery-beat
See https://github.com/celery/django-celery-beat/issues/977
2026-01-19 10:09:29 +00:00
Marie PUPO JEAMMET 7d44e54913 (admin) manage aliases from admin
manage aliases from admin, including alias creation,
deletion and sync
2026-01-16 15:19:37 +00:00
Marie PUPO JEAMMET 01583ba94f 🐛(aliases) sort aliases by local part
sort aliases by local part to ease front-end interface
2026-01-16 15:19:37 +00:00
Marie PUPO JEAMMET 5feee53bdd 🔒️(security) upgrade python version to fix vulnerability
Vulnerability in jaraco.context caused security issue
in setuptools and python3. change python version to fix
see https://github.com/advisories/GHSA-58pv-8j8x-9vj2
2026-01-16 11:23:55 +00:00
Marie PUPO JEAMMET 9c62efc9f8 🐛(dimail) authorize alias and mailbox with same local part
a mailbox and alias can coexist despite having the same local part
2026-01-15 10:29:49 +00:00
Marie PUPO JEAMMET d2ef9e0beb 🐛(dimail) ignore oxadmin mailboxes when importing mailboxes
oxadmin mailbox are technical mailboxes used by dimail. It should
not be imported when importing mailboxes from dimail.
2026-01-15 10:29:49 +00:00
Marie PUPO JEAMMET bc1cbef168 (aliases) delete all aliases of a given local part
added a bulk delete method for aliases, when filtering on local part
this is convenient when in need to delete the local part and all its
destinations in a single call
2026-01-12 14:31:53 +00:00
Marie PUPO JEAMMET 8ab1b2e2ef 🔧(settings) separate dimail container from app-dev
separate dimail container from app-dev to avoid setting
dimail container everytime we run the test suite.
2026-01-12 14:31:53 +00:00
Marie PUPO JEAMMET f498e3b6d2 ♻️(lint) mark a few dimail methods as private
mark a few dimail methods as private, to calm linter
2026-01-12 14:31:53 +00:00
Marie PUPO JEAMMET e47573e22f (aliases) delete aliases
delete a single alias using its pk
2026-01-12 14:31:53 +00:00
Sabrina Demagny c7aab6f5b5 🔥(plugins) remove CommuneCreation plugin
Remove the French communes auto-provisioning plugin that handled
SIRET lookup, DNS setup, and MailDomain creation.
2026-01-09 09:38:01 +01:00
renovate[bot] 2144ad5da1 ⬆️(dependencies) update next to v15.4.10 [SECURITY] 2025-12-15 14:43:33 +00:00
renovate[bot] f00deeebe9 ⬆️(dependencies) update next to v15.4.9 [SECURITY] 2025-12-12 12:41:33 +00:00
Marie PUPO JEAMMET 30aea9b350 🔖(minor) release version 1.21.0
Update all version files and changelog for minor release.
2025-12-05 16:34:06 +00:00
Marie PUPO JEAMMET e01b422c13 (mailboxes) check imported mailboxes don't clash with existing alias
check that dimail imported mailboxes don't use the same username
as existing aliases.
2025-12-05 15:03:17 +00:00
Marie PUPO JEAMMET 040f949e5e (aliases) import existing aliases from dimail
import existing aliases from dimail, making sure usernames don't clash with
existing mailboxes. Convenient when people fall out of sync
with dimail or for domains partially operated outside people.
2025-12-05 15:03:17 +00:00
renovate[bot] befcecf33c ⬆️(dependencies) update django to v5.2.9 [SECURITY] 2025-12-04 13:21:36 +00:00
renovate[bot] c5edabf3b0 ⬆️(dependencies) update next to v15.4.8 [SECURITY] 2025-12-04 09:36:36 +00:00
Marie PUPO JEAMMET 3a2b5a6428 🛂(permissions) return 404 to users with no domain access
users having no domain access should be forbidden to know the domain is
managed in people. To this effect, 403_FORBIDDEN responses have been
replaced by 404_NOT_FOUND.
2025-11-21 17:04:20 +00:00
Marie PUPO JEAMMET 15337a2226 (aliases) warn that domain is out of sync when unexpected 404
deleting an alias should trigger a request to dimail. if dimail's
response if a 404, it means people and dimail are out of sync with
each other. log error and warn user
2025-11-20 11:24:59 +00:00
Marie PUPO JEAMMET 0f7e312eb6 (mailboxes) cannot create mailbox with same local part as alias
should not be able to create a mailbox having the same local part as an alias
2025-11-20 11:24:59 +00:00
Marie PUPO JEAMMET 23561cd0e0 🔥(sops) remove obsolete sops file
remove obsolete sops file
2025-11-20 11:24:59 +00:00
Marie PUPO JEAMMET 53d0336755 (aliases) delete aliases
add feature to delete aliases
2025-11-20 11:24:59 +00:00
Marie PUPO JEAMMET b79e12b4be (aliases) list aliases
Can GET a list of all aliases of a domain
2025-11-20 11:24:59 +00:00
Marie PUPO JEAMMET c237bb4b10 (aliases) create aliases
allow domain managers to create aliases on their domain
2025-11-20 11:24:59 +00:00
renovate[bot] 64068efff4 ⬆️(dependencies) update django to v5.2.8 [SECURITY] 2025-11-06 00:11:45 +01:00
renovate[bot] 6866babb32 ⬆️(dependencies) update Brotli to v1.2.0 [SECURITY] 2025-11-06 00:11:04 +01:00
Quentin BEY 80caa062e9 🐛(mail-domains) fix zod resolver use in forms
References:
https://github.com/react-hook-form/react-hook-form/issues/12816
https://github.com/react-hook-form/resolvers/issues/768
2025-11-04 16:10:27 +01:00
Quentin BEY 513dbe6f83 💚(frontend) fix using side effects within waitFor callback
New lint error detected.
2025-11-04 16:10:27 +01:00
Marie PUPO JEAMMET 490fdd2ed4 🐛(tests) fix lint-front and test-front errors
fix dependency import error and failing e2e tests due to
@tanstack upgrade
2025-11-04 16:10:27 +01:00
Marie PUPO JEAMMET fa7d24545f 🔥(config) remove empty duplicate yarn.lock
remove duplicate yarn.lock from previous config attempts
2025-11-04 16:10:27 +01:00
Marie PUPO JEAMMET e5938e144e 📌(frontend) hold next to v15.4.7
Avoid renovate to bump next to its latest major version.
2025-11-04 16:10:27 +01:00
Marie PUPO JEAMMET 39af4313cf 🚨(frontend) adapt signatures to @tanstack/react-query to >5.90
Recent upgrade of @tanstack/react-query to
version >5.90 introduced a breaking change in the
onSuccess and onError callback signatures for
the useMutation hook.
The context parameter has been replaced with an
onMutateResult parameter, which provides
information about the result of the
onMutate callback.

Shamelessly copied from https://github.com/suitenumerique/docs/pull/1375/commits
2025-11-04 16:10:27 +01:00
Marie PUPO JEAMMET abfe32569e ⬇️(dependencies) hold cunningham to v3.2.3
Cunningham v4.0.0 is not yet compatible with lasuite ui-kit
2025-11-04 16:10:27 +01:00
renovate[bot] 964981bbfb ⬆️(dependencies) update js dependencies 2025-11-04 16:10:27 +01:00
mjeammet 33faa5f224 🌐(i18n) update translated strings
Update translated files with new translations
2025-10-22 10:18:21 +00:00
Marie PUPO JEAMMET 99967b450e 🔖(minor) release version 1.20.0
Update all version files and changelog for minor release.
2025-10-22 10:18:21 +00:00
Marie PUPO JEAMMET 71a7bf688f 🐛(mailbox) fix case-sensitive duplicate display names
uniqueness on first name + last name was case-sensitive, which allowed
duplicates
2025-10-22 08:22:48 +00:00
Marie PUPO JEAMMET 302671bc69 ⬆️(dependencies) upgrade node to 22
node 18 reached end-of-life and is now unsupported. we jump straight
to 22 as recommended here
https://nodejs.org/en/blog/announcements/node-18-eol-support
2025-10-21 11:52:51 +00:00
Marie PUPO JEAMMET 4262f469d6 ♻️(tests) refacto dimail tests with fixtures
test_api_mailboxes_create exceeded 1000 lines. By using fixtures, we can
at least factorize dimail response when token is ok or mailbox_data sent
to our API when creating a mailbox.
2025-10-14 10:00:28 +00:00
Marie PUPO JEAMMET b24cb23a83 (models) impose uniqueness on display name, to match ox's constraint
OpenXchange's primary key is display name (= first name + last name).
It must be unique in the domain's context. We don't have context info
but we can impose uniqueness by domain.
2025-10-14 10:00:28 +00:00
Marie PUPO JEAMMET 608f8c6988 🐛(dimail) grab duplicate displayname error
OpenXchange's primary key is display name (= first name + last name).
In absence of clear error message from dimail (yet), we catch errors 500 and
check if they're not due to the display name already existing in the context
2025-10-14 10:00:28 +00:00
renovate[bot] 2ddfef59d8 ⬆️(dependencies) update next to v15.4.7 [SECURITY] 2025-10-13 10:01:30 +00:00
renovate[bot] 6b8cb16bd5 ⬆️(dependencies) update django to v5.2.7 [SECURITY] 2025-10-13 08:50:32 +00:00
Marie PUPO JEAMMET 8b89a1112d 📝(release) update release.md
Update documentation on release
2025-09-23 18:56:56 +02:00
Laurent Bossavit 230ff21220 (mailbox) synchronize password of newly created mailbox with Dimail's
When using La Régie as Identity Provider this allows signing in.
2025-09-23 17:40:08 +02:00
Quentin BEY a37c2a8e83 🔒️(frontend) update alpine packages in production image
Force an update of installed package in the image used for
the frontend in production.
2025-09-23 14:31:13 +02:00
mjeammet 80da8bea74 🌐(i18n) update translated strings
Update translated files with new translations
2025-09-19 17:04:02 +02:00
Marie PUPO JEAMMET 76f4bf36c7 🔖(patch) release version 1.19.1
Update all version files and changelog for patch release.
2025-09-19 17:04:02 +02:00
Eléonore Voisin 78a5d907ca 🐛(fix) add enabled update your mailbox
can update your mailbox as viewer
2025-09-19 16:45:40 +02:00
Marie PUPO JEAMMET 0ad60ab292 ⬆️(backend) bump Django to 5.2.6
Bump Django version to 5.2.6, fixing CVE-2025-57833. See
https://www.djangoproject.com/weblog/2025/sep/03/security-releases/
2025-09-15 14:36:26 +02:00
Quentin BEY ac443d3b6f 🔒️(all) refactor Docker Hub login to use official GitHub actions
Replace custom Docker Hub authentication with standard, secure,
official GitHub actions for improved security and maintainability.

Uses officially supported actions that follow security best practices
and receive regular updates from GitHub.

Avoid unsecure handling of GitHub secrets.

Thanks to @lebaudantoine
2025-09-05 14:55:17 +02:00
Quentin BEY 48ff0e9f3a 🔧(ci) always run all git-lint steps
git-lint steps are independant and we would like to have all checks at
once. Using the `if: always()` instruction should ensure all steps
should be run event if the previous fails.

thanks @lunika
2025-09-05 14:44:20 +02:00
mjeammet 675e719a22 🌐(i18n) update translated strings
Update translated files with new translations
2025-09-03 11:52:42 +02:00
mjeammet 0875fae87b 🌐(i18n) update translated strings
Update translated files with new translations
2025-09-03 11:52:42 +02:00
Marie PUPO JEAMMET 668a296142 🔖(minor) release version 1.19.0
Update all version files and changelog for minor release.
2025-09-03 11:52:42 +02:00
elvoisin f1892b7049 (front) add modal update mailboxes (#954)
* (front) add modal update mailboxes

add modal update mailboxes

* ️(labels) improve aria-labels on domain panel's buttons

improve descriptions on aria-label on domain panel's buttons

---------

Co-authored-by: Marie PUPO JEAMMET <marie.pupojeammet@numerique.gouv.fr>
2025-09-03 11:02:23 +02:00
Marie PUPO JEAMMET 1bfad507ef (api) retrieve mailboxes
add feature to retrieve mailboxes when having the right access
2025-09-02 13:45:26 +02:00
Marie PUPO JEAMMET 72e73bff45 (api) give update rights to domain viewer on own mailbox
Introduces the notion of self in permissions
allowing a domain viewer to update their own mailbox.
2025-09-02 13:45:26 +02:00
Marie PUPO JEAMMET e45cf8dd8b (api) update mailboxes
Allow update of mailboxes. Secondary email, first and last names can be updated
but not domain or local_part.
2025-09-02 13:45:26 +02:00
Marie PUPO JEAMMET 79f8e5276a 💚(ci) fix changelog CI job
The command git watchanged is deprecated and is flag to be removed soon.
It can be easily replace with git log
2025-08-29 15:32:26 +02:00
Marie PUPO JEAMMET 4f97685204 🐛(admin) fix mailbox import from dimail
importing mailboxes from dimail was broken due to a change of format in dimail's
response.
2025-08-29 11:46:33 +02:00
Marie PUPO JEAMMET 935058582e ⬆️(dependencies) upgrade dimail to 0.5.2
manually upgrade dimail to match prod version
2025-08-29 11:46:33 +02:00
Marie PUPO JEAMMET 636b27321b 🧑‍💻(demo) set invitations for local user
prepare invitations for local user. Upon first connexion
to keycloack, invitations are consumed and dev are ready to go
:)
2025-08-26 17:39:58 +02:00
Marie PUPO JEAMMET 2fdddb3e12 🧑‍💻(demo) add mailboxes to demo
add mailboxes to demo domains.
2025-08-26 17:39:58 +02:00
Quentin BEY f7a97e11e8 🎨(ruff) fix linter error after ruff update
Fix the `make lint` errors after dependencies update.
2025-08-26 11:36:05 +02:00
renovate[bot] a56f86965f ⬆️(dependencies) update python dependencies 2025-08-26 09:23:11 +00:00
Hsiaoming Yang 89674ad718 🎨(joserfc) fix import path of RSAKey
`rfc7518` module will be removed in v1.4.0 of joserfc.
2025-08-26 10:59:35 +02:00
Quentin BEY 4e592382dc 💥(sentry) remove DJANGO_ before Sentry DSN env variable
Other "La Suite" projects don't have the "DJANGO_" prefix, so
we align this project with others.
2025-07-24 23:53:47 +02:00
Marie PUPO JEAMMET 1fdd2e052b 🐛(config) remove default sentry_dsn
set default sentry_dsn to None to avoid all outside
deployments sending infos to our sentry instance.
2025-07-23 12:08:28 +02:00
Quentin BEY 25918830ff ⬆️(django-lasuite) bump to version 0.0.11
This allows to accept JWE from new Proconnect introspection endpoint.
2025-07-09 14:47:55 +02:00
Marie PUPO JEAMMET b47282430e 🔖(patch) release version 1.18.2
Update all version files and changelog for patch release.
2025-07-03 11:28:01 +02:00
Marie PUPO JEAMMET ebd3f467c4 🐛(admin) fix read_only preventing mailbox creation in admin
removed read_only on "local_part" and "domain" which prevented
admin user from creating mailboxes from admin
2025-07-02 18:06:02 +02:00
Marie PUPO JEAMMET 0f4ec63a9f 🐛(webhooks) fix special case for tchap integration
we want to use Tchap integration server for our own preprod
but it doesn't follow the same url naming convention as the
other Tchap instances, so it requires a special treatment.
This quick fix should do it.
2025-07-02 17:52:44 +02:00
Eléonore Voisin fdec3b4196 🐛(front) fix missing pagination mail domains
fix position div ref
2025-07-02 17:38:21 +02:00
Marie PUPO JEAMMET b78723ecce 🔖(patch) release version 1.18.1
Update all version files and changelog for patch release.
2025-07-02 11:29:10 +02:00
Marie PUPO JEAMMET df0cea225c 🐛(changelog) add missing changelog
add missing changelog for consistency
2025-07-02 11:29:10 +02:00
mjeammet 3b73cca2f5 🌐(i18n) update translated strings
Update translated files with new translations
2025-07-02 11:29:10 +02:00
elvoisin 06d4d5c9e8 🐛(front) fix missing pagination mail domains (#946)
fix missing pagination mail domains + mailboxes list
2025-07-02 09:03:11 +02:00
135 changed files with 8463 additions and 4105 deletions
+1 -1
View File
@@ -10,7 +10,7 @@ jobs:
install-dependencies:
uses: ./.github/workflows/dependencies.yml
with:
node_version: '18.x'
node_version: '22.x'
with-front-dependencies-installation: true
synchronize-with-crowdin:
+2 -2
View File
@@ -10,7 +10,7 @@ jobs:
install-dependencies:
uses: ./.github/workflows/dependencies.yml
with:
node_version: '18.x'
node_version: '22.x'
with-front-dependencies-installation: true
with-build_mails: true
@@ -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
+1 -1
View File
@@ -5,7 +5,7 @@ on:
inputs:
node_version:
required: false
default: '18.x'
default: '22.x'
type: string
with-front-dependencies-installation:
type: boolean
+8 -2
View File
@@ -58,7 +58,10 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
- name: create-version-json
id: create-version-json
uses: jsdaniell/create-json@v1.2.3
@@ -99,7 +102,10 @@ jobs:
-
name: Login to DockerHub
if: github.event_name != 'pull_request'
run: echo "${{ secrets.DOCKER_HUB_PASSWORD }}" | docker login -u "${{ secrets.DOCKER_HUB_USER }}" --password-stdin
uses: docker/login-action@v3
with:
username: ${{ secrets.DOCKER_HUB_USER }}
password: ${{ secrets.DOCKER_HUB_PASSWORD }}
-
name: Build and push
uses: docker/build-push-action@v6
+8 -4
View File
@@ -13,7 +13,7 @@ jobs:
dependencies:
uses: ./.github/workflows/dependencies.yml
with:
node_version: '18.x'
node_version: '22.x'
with-front-dependencies-installation: true
with-build_mails: true
@@ -28,14 +28,18 @@ jobs:
- name: show
run: git log
- name: Enforce absence of print statements in code
if: always()
run: |
! git diff origin/${{ github.event.pull_request.base.ref }}..HEAD -- . ':(exclude)**/people.yml' | grep -E "(\bprint\(|\bpprint\()"
- name: Check absence of fixup commits
if: always()
run: |
! git log | grep 'fixup!'
- name: Install gitlint
if: always()
run: pip install --user requests gitlint
- name: Lint commit messages added to main
if: always()
run: ~/.local/bin/gitlint --commits origin/${{ github.event.pull_request.base.ref }}..HEAD
check-changelog:
@@ -49,7 +53,7 @@ jobs:
with:
fetch-depth: 0
- name: Check that the CHANGELOG has been modified in the current branch
run: git whatchanged --name-only --pretty="" origin/${{ github.event.pull_request.base.ref }}..HEAD | grep CHANGELOG
run: git log --name-only --pretty="" origin/${{ github.event.pull_request.base.ref }}..HEAD | grep CHANGELOG
lint-changelog:
runs-on: ubuntu-latest
@@ -220,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
@@ -277,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)
-11
View File
@@ -1,11 +0,0 @@
creation_rules:
- path_regex: ./*
key_groups:
- age:
- age15fyxdwmg5mvldtqqus87xspuws2u0cpvwheehrtvkexj4tnsqqysw6re2x # jacques
- age16hnlml8yv4ynwy0seer57g8qww075crd0g7nsundz3pj4wk7m3vqftszg7 # github-repo
- age1plkp8td6zzfcavjusmsfrlk54t9vn8jjxm8zaz7cmnr7kzl2nfnsd54hwg # Anthony Le-Courric
- age12g6f5fse25tgrwweleh4jls3qs52hey2edh759smulwmk5lnzadslu2cp3 # Antoine Lebaud
- age1hnhuzj96ktkhpyygvmz0x9h8mfvssz7ss6emmukags644mdhf4msajk93r # Samuel Paccoud
- age1tl80n23wq6zxegupwn70ew0yp225ua5v4dk800x7g2w6pvlxz46qk592pa # Marie Pupo Jeammet
- age1rjchule5sncn8r8gfph07muee6vzx4wqfrtldt5jjzke4vlfxy2qqplfvc # Quentin Bey
+89 -1
View File
@@ -8,10 +8,89 @@ 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
- 🛂(permissions) return 404 to users with no access to domain #985
- ✨(aliases) can create, list and delete aliases #974
## [1.20.0] - 2025-10-22
- ✨(models) impose uniqueness on display name, to match ox's constraint
- 🐛(dimail) catch duplicate displayname error
- ✨(mailbox) synchronize password of newly created mailbox with Dimail's
## [1.19.1] - 2025-09-19
- 🐛(fix) add enabled update your mailbox
## [1.19.0] - 2025-09-03
- ✨(front) add modal update mailboxes #954
### Added
- ✨(api) update mailboxes #934
- ✨(api) give update rights to domain viewer on own mailbox #934
### Fixed
- 🐛(dimail) grab duplicate displayname error #961
### Changed
- 💥(sentry) remove `DJANGO_` before Sentry DSN env variable #957
## [1.18.2] - 2025-07-03
### Fixed
- 🐛(front) fix missing pagination mail domains #950
## [1.18.1] - 2025-07-02
### Fixed
- 🐛(front) fix missing pagination on mail domains #946
## [1.18.0] - 2025-06-30
### Added
- 🐛(front) fix missing pagination mail domains
- 🐛(front) fix button add mail domain
- ✨(teams) add matrix webhook for teams #904
- ✨(resource-server) add SCIM /Me endpoint #895
@@ -411,7 +490,16 @@ 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.18.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
[1.19.0]: https://github.com/suitenumerique/people/releases/v1.19.0
[1.18.2]: https://github.com/suitenumerique/people/releases/v1.18.2
[1.18.1]: https://github.com/suitenumerique/people/releases/v1.18.1
[1.18.0]: https://github.com/suitenumerique/people/releases/v1.18.0
[1.17.0]: https://github.com/suitenumerique/people/releases/v1.17.0
[1.16.0]: https://github.com/suitenumerique/people/releases/v1.16.0
+3 -3
View File
@@ -1,10 +1,10 @@
# Django People
# ---- base image to inherit from ----
FROM python:3.12.6-alpine3.20 AS base
FROM python:3.13.11-alpine AS base
# Upgrade pip to its latest release to speed up dependencies installation
RUN python -m pip install --upgrade pip setuptools
RUN python -m pip install --upgrade pip
# Upgrade system packages to install security updates
RUN apk update && \
@@ -23,7 +23,7 @@ RUN mkdir /install && \
# ---- mails ----
FROM node:20 AS mail-builder
FROM node:22 AS mail-builder
COPY ./src/mail /mail/app
+2 -1
View File
@@ -108,6 +108,7 @@ bootstrap: \
# -- Docker/compose
build: ## build the app-dev container
@$(COMPOSE) build app-dev
@$(COMPOSE) build dimail
.PHONY: build
down: ## stop and remove containers, networks, images, and volumes
@@ -123,7 +124,7 @@ run: ## start the wsgi (production) and servers with production Docker images
.PHONY: run
run-dev: ## start the servers in development mode (watch) Docker images
@$(COMPOSE) up --force-recreate --detach app-dev frontend-dev celery-dev celery-beat-dev nginx maildev
@$(COMPOSE) up --force-recreate --detach app-dev dimail frontend-dev celery-dev celery-beat-dev nginx maildev
.PHONY: run-dev
status: ## an alias for "docker compose ps"
+2 -4
View File
@@ -46,8 +46,6 @@ services:
postgresql:
condition: service_healthy
restart: true
dimail:
condition: service_started
maildev:
condition: service_started
redis:
@@ -199,7 +197,7 @@ services:
working_dir: /app
node:
image: node:18
image: node:22
user: "${DOCKER_USER:-1000}"
environment:
HOME: /tmp
@@ -259,7 +257,7 @@ services:
dimail:
entrypoint: /opt/dimail-api/start-dev.sh
image: registry.mim-libre.fr/dimail/dimail-api:v0.2.11
image: registry.mim-libre.fr/dimail/dimail-api:v0.5.2
pull_policy: always
environment:
DIMAIL_MODE: FAKE
+27 -23
View File
@@ -17,48 +17,52 @@ Whenever we are cooking a new release (e.g. `4.18.1`) we should follow a standar
This script will ask you for the version you want to release and the kind of release (patch, minor, major). It will then:
1. Create a new branch named: `release/4.18.1`.
- Create a new branch named: `release/4.18.1`.
2. Bump the release number for backend and frontend project.
- Bump the release number for backend and frontend project.
3. Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations
- Update the project's `Changelog` following the [keepachangelog](https://keepachangelog.com/en/0.3.0/) recommendations
4. Commit your changes with the following format: the 🔖 release emoji, the type of release (patch/minor/patch) and the release version:
- Commit your changes with the following format: 🔖 release emoji, type of release (patch/minor/patch) and release version:
```text
🔖(minor) release version 4.18.1
```
3. Open a pull request ask you to wait for an approval from your peers and merge it.
- Triggers Crowdin translation action and open related PR
4. Ask you to tag and push your commit:
- Open release PR. Wait for an approval from your peers and merge it.
> [!NOTE]
> It also open the PR for pre-prod deployment, see following section.
3. Following release script instructions,
- merge translation and release pulls requests
- tag and push your commit:
```bash
git tag v4.18.1 && git push origin tag v4.18.1
```
Doing this triggers the CI and tells it to build the new Docker image versions that you targeted earlier in the Helm files.
This triggers the CI building new Docker images. You can ensure images were successfully built on Docker Hub [here for back-end](https://hub.docker.com/r/lasuite/people-backend/tags) and [here for front-end](https://hub.docker.com/r/lasuite/people-frontend/tags).
5. Ensure the new [backend](https://hub.docker.com/r/lasuite/people-backend/tags) and [frontend](https://hub.docker.com/r/lasuite/people-frontend/tags) image tags are on Docker Hub.
6. Create a PR on the [lasuite-deploiement](https://github.com/numerique-gouv/lasuite-deploiement) repository to bump the preprod version.
# Deploying
7. The release is now done!
## Staging
# Deploying
The `staging` platform is deployed automatically with every update of the `main` branch.
> [!TIP]
> The `staging` platform is deployed automatically with every update of the `main` branch.
## Pre-prod and production
Making a new release doesn't publish it automatically in production.
If you used the release script and had permission to push on [lasuite-deploiement](https://github.com/numerique-gouv/lasuite-deploiement), a deployment branch has been created. You can skip step 1.
Deployment is done by ArgoCD. ArgoCD checks for the `production` tag and automatically deploys the production platform with the targeted commit.
Otherwise, for manual preprod and for production deployments :
1. Bump tag version for both front-end and back-end images in the `.gotmpl` file located in `manifests/<your-product>/env.d/<your-environment>/`,
2. Add optional new secrets and variables, if applicable
3. Create a pull request
4. Submit to approval and merge PR
To publish, we mark the commit we want with the `production` tag. ArgoCD is then notified that the tag has changed. It then deploys the Docker image tags specified in the Helm files of the targeted commit.
To publish the release you just made:
```bash
git tag --force production v4.18.1
git push --force origin production
```
The release is now done! 🎉
-1
View File
@@ -1,2 +1 @@
INSTALLED_PLUGINS=plugins.la_suite
DNS_PROVISIONING_TARGET_ZONE=test.collectivite.fr
@@ -17,8 +17,6 @@ from core.models import (
AccountService,
Contact,
Organization,
OrganizationAccess,
OrganizationRoleChoices,
)
logger = logging.getLogger(__name__)
@@ -131,15 +129,6 @@ class OIDCAuthenticationBackend(LaSuiteOIDCAuthenticationBackend):
user = super().create_user(claims | {"organization": organization})
if organization_created:
# Warning: we may remove this behavior in the near future when we
# add a feature to claim the organization ownership.
OrganizationAccess.objects.create(
organization=organization,
user=user,
role=OrganizationRoleChoices.ADMIN,
)
# Initiate the user's profile
Contact.objects.create(
owner=user,
-1
View File
@@ -1,4 +1,3 @@
# ruff: noqa: S311
"""
Core application factories
"""
+2 -1
View File
@@ -18,6 +18,7 @@ class BasePluginAppConfigMixIn:
Initialize the hooks registry when the application is ready.
This is called by Django when the application is loaded.
"""
from .registry import registry # pylint: disable=import-outside-toplevel
# pylint: disable=import-outside-toplevel
from .registry import registry # noqa: PLC0415
registry.register_app(self.name) # pylint: disable=no-member
@@ -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
@@ -9,7 +8,7 @@ from cryptography.hazmat.primitives import serialization
from cryptography.hazmat.primitives.asymmetric import rsa
from joserfc import jwe as jose_jwe
from joserfc import jwt as jose_jwt
from joserfc.rfc7518.rsa_key import RSAKey
from joserfc.jwk import RSAKey
from jwt.utils import to_base64url_uint
from lasuite.oidc_resource_server.authentication import (
ResourceServerAuthentication,
@@ -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(
@@ -230,6 +230,9 @@ def test_matrix_webhook__override_secret_for_tchap():
headers = responses.calls[0].request.headers
assert "TCHAP_TOKEN" in headers["Authorization"]
# Check correctly inferred base url from room_id
assert "matrix.home_server" in responses.calls[0].request.url
## KICK
@responses.activate
@@ -247,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)
@@ -293,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(
@@ -342,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(
+1 -1
View File
@@ -45,7 +45,7 @@ class MatrixAPIClient:
"""Returns room id from webhook url."""
room_id = webhook_url.split("/room/")[1]
base_url = room_id.split(":")[1]
if "tchap.gouv.fr" in webhook_url:
if "tchap.gouv.fr" in webhook_url and "i.tchap.gouv.fr" not in webhook_url:
base_url = f"matrix.{base_url}"
return f"https://{base_url}/_matrix/client/v3/rooms/{room_id}"
+1 -1
View File
@@ -4,5 +4,5 @@ NB_OBJECTS = {
"users": 1000,
"teams": 100,
"max_users_per_team": 100,
"domains": 20,
"domains": 10,
}
@@ -181,7 +181,7 @@ def create_oidc_people_idp_client_user():
)
def create_demo(stdout): # pylint: disable=too-many-locals
def create_demo(stdout): # pylint: disable=too-many-branches too-many-statements too-many-locals # noqa: PLR0912, PLR0915
"""
Create a database with demo data for developers to work in a realistic environment.
The code is engineered to create a huge number of objects fast.
@@ -303,6 +303,30 @@ def create_demo(stdout): # pylint: disable=too-many-locals
queue.flush()
with Timeit(stdout, "Creating mailboxes"):
domains_ids = list(
mailbox_models.MailDomain.objects.values_list("id", flat=True)
)
for domain_id in domains_ids:
for i in range(random.randint(1, 10)):
first_name = fake.first_name()
last_name = fake.last_name()
local_part = f"{first_name.lower()}.{last_name.lower()}{i}"
queue.push(
mailbox_models.Mailbox(
first_name=first_name,
last_name=last_name,
local_part=local_part,
domain_id=domain_id,
secondary_email=f"{local_part}@example.fr",
status=random.choice(MailboxStatusChoices.values),
dn_email=local_part,
)
)
queue.flush()
with Timeit(stdout, "Creating specific users"):
# ⚠️ Warning: this users also need to be created in the keycloak
# realm.json AND the OIDC setting to fallback on user email
@@ -371,17 +395,56 @@ def create_demo(stdout): # pylint: disable=too-many-locals
queue.flush()
# Enabled domain for 2E2 tests
enabled_domain, _created = mailbox_models.MailDomain.objects.get_or_create(
name="enabled-domain.com",
status=MailDomainStatusChoices.ENABLED,
support_email="support@enabled-domain.com",
)
domain_owner = models.User.objects.get(email="e2e.mail-owner@example.com")
mailbox_models.MailDomainAccess.objects.get_or_create(
domain=enabled_domain,
user=domain_owner,
role=MailDomainRoleChoices.OWNER,
# Enabled domain for 2E2 tests
enabled_domain, _created = mailbox_models.MailDomain.objects.get_or_create(
name="enabled-domain.com",
status=MailDomainStatusChoices.ENABLED,
support_email="support@enabled-domain.com",
)
domain_owner = models.User.objects.get(email="e2e.mail-owner@example.com")
mailbox_models.MailDomainAccess.objects.get_or_create(
domain=enabled_domain,
user=domain_owner,
role=MailDomainRoleChoices.OWNER,
)
mailbox_models.MailDomainInvitation.objects.create(
issuer=domain_owner,
domain=enabled_domain,
email="people@people.world",
role=MailDomainRoleChoices.ADMIN,
)
# Many mailboxes domain
many_boxes_domain, _created = mailbox_models.MailDomain.objects.get_or_create(
name="many-boxes-domain.com",
status=MailDomainStatusChoices.ENABLED,
support_email="support@mbd.com",
)
domain_owner = models.User.objects.get(email="e2e.mail-owner@example.com")
mailbox_models.MailDomainAccess.objects.get_or_create(
domain=many_boxes_domain,
user=domain_owner,
role=MailDomainRoleChoices.OWNER,
)
mailbox_models.MailDomainInvitation.objects.create(
issuer=domain_owner,
domain=many_boxes_domain,
email="people@people.world",
role=MailDomainRoleChoices.OWNER,
)
for _i in range(30):
first_name = fake.first_name()
last_name = fake.last_name()
local_part = f"{first_name.lower()}.{last_name.lower()}"
mailbox_models.Mailbox.objects.create(
domain=many_boxes_domain,
first_name=first_name,
last_name=last_name,
local_part=local_part,
secondary_email=f"{local_part}@example.fr",
status=random.choice(MailboxStatusChoices.values),
password=make_password(None), # unusable password
dn_email=f"{local_part}@{many_boxes_domain}",
)
# OIDC configuration
@@ -19,7 +19,7 @@ TEST_NB_OBJECTS = {
"users": 100,
"teams": 100,
"max_users_per_team": 5,
"domains": 100,
"domains": 10,
}
@@ -40,16 +40,19 @@ def test_commands_create_demo(settings):
assert models.Team.objects.count() == TEST_NB_OBJECTS["teams"]
assert models.TeamAccess.objects.count() >= TEST_NB_OBJECTS["teams"]
# nb_domains + example.com + enabled + many-boxed-domain
assert (
mailbox_models.MailDomain.objects.count() == TEST_NB_OBJECTS["domains"] + 1 + 1
mailbox_models.MailDomain.objects.count()
== TEST_NB_OBJECTS["domains"] + 1 + 1 + 1
)
# 3 domain access for each user with domain rights
# 3 x 3 domain access for each user with both rights
# 1 domain for E2E mail owner user
# 2 domains for E2E mail owner user
assert (
mailbox_models.MailDomainAccess.objects.count()
== TEST_NB_OBJECTS["domains"] + 3 + 9 + 1
== TEST_NB_OBJECTS["domains"] + 3 + 9 + 2
)
+100 -77
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-06-19 15:31+0000\n"
"PO-Revision-Date: 2025-06-30 10:05\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:53 core/models.py:250 core/models.py:364
#: core/models.py:510 core/models.py:1121 mailbox_manager/models.py:53
#: 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:17 mailbox_manager/admin.py:17
#: build/lib/mailbox_manager/admin.py:14 mailbox_manager/admin.py:14
msgid "Import emails from dimail"
msgstr ""
#: build/lib/mailbox_manager/admin.py:35 mailbox_manager/admin.py:35
#: 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:42 mailbox_manager/admin.py:42
#: 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:49 mailbox_manager/admin.py:49
#: 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:54 mailbox_manager/admin.py:54
#: 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:71 mailbox_manager/admin.py:71
#: 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:84 mailbox_manager/admin.py:84
#: 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:85 mailbox_manager/admin.py:85
#, python-format
msgid "Domains updated: %(domains)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:87 mailbox_manager/admin.py:87
msgid "No domain updated."
msgstr ""
#: build/lib/mailbox_manager/admin.py:94 mailbox_manager/admin.py:94
msgid "Check domain failed for:"
msgstr ""
#: build/lib/mailbox_manager/admin.py:102 mailbox_manager/admin.py:102
#, python-format
msgid "Domains disabled are excluded from check: %(domains)s"
msgstr ""
#: build/lib/mailbox_manager/admin.py:107 mailbox_manager/admin.py:107
msgid "Fetch domain expected config from dimail"
msgstr ""
#: build/lib/mailbox_manager/admin.py:121 mailbox_manager/admin.py:121
#, python-format
msgid "Domain expected config fetched with success for %(domain)s."
msgstr ""
#: 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:129 mailbox_manager/admin.py:129
msgid "No domain updated."
msgstr ""
#: build/lib/mailbox_manager/admin.py:136 mailbox_manager/admin.py:136
msgid "Check domain failed for:"
msgstr ""
#: 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:149 mailbox_manager/admin.py:149
msgid "Fetch domain expected config from dimail"
msgstr ""
#: 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: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:133 mailbox_manager/admin.py:133
#: 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:138 mailbox_manager/admin.py:138
#: 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:154 mailbox_manager/admin.py:154
#: 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:160 mailbox_manager/admin.py:160
#: 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:166 mailbox_manager/admin.py:166
#: build/lib/mailbox_manager/admin.py:208 mailbox_manager/admin.py:208
#, python-format
msgid "Domains disabled are excluded from : %(domains)s"
msgstr ""
@@ -476,97 +487,109 @@ msgstr ""
msgid "Action required"
msgstr ""
#: build/lib/mailbox_manager/models.py:32 mailbox_manager/models.py:32
#: 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:37 mailbox_manager/models.py:37
#: 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:42 mailbox_manager/models.py:42
#: 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:68 mailbox_manager/models.py:68
#: build/lib/mailbox_manager/models.py:70 mailbox_manager/models.py:70
msgid "support email"
msgstr ""
#: build/lib/mailbox_manager/models.py:72 mailbox_manager/models.py:72
#: build/lib/mailbox_manager/models.py:74 mailbox_manager/models.py:74
msgid "last check details"
msgstr ""
#: build/lib/mailbox_manager/models.py:73 mailbox_manager/models.py:73
#: 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:78 mailbox_manager/models.py:78
#: build/lib/mailbox_manager/models.py:80 mailbox_manager/models.py:80
msgid "expected config"
msgstr ""
#: build/lib/mailbox_manager/models.py:79 mailbox_manager/models.py:79
#: 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:84 mailbox_manager/models.py:84
#: build/lib/mailbox_manager/models.py:86 mailbox_manager/models.py:86
msgid "Mail domain"
msgstr ""
#: build/lib/mailbox_manager/models.py:85 mailbox_manager/models.py:85
#: build/lib/mailbox_manager/models.py:87 mailbox_manager/models.py:87
msgid "Mail domains"
msgstr ""
#: build/lib/mailbox_manager/models.py:199 mailbox_manager/models.py:199
#: build/lib/mailbox_manager/models.py:203 mailbox_manager/models.py:203
msgid "User/mail domain relation"
msgstr ""
#: build/lib/mailbox_manager/models.py:200 mailbox_manager/models.py:200
#: build/lib/mailbox_manager/models.py:204 mailbox_manager/models.py:204
msgid "User/mail domain relations"
msgstr ""
#: build/lib/mailbox_manager/models.py:273 mailbox_manager/models.py:273
#: build/lib/mailbox_manager/models.py:277 mailbox_manager/models.py:277
msgid "local_part"
msgstr ""
#: build/lib/mailbox_manager/models.py:287 mailbox_manager/models.py:287
#: build/lib/mailbox_manager/models.py:291 mailbox_manager/models.py:291
msgid "secondary email address"
msgstr ""
#: build/lib/mailbox_manager/models.py:298 mailbox_manager/models.py:298
#: build/lib/mailbox_manager/models.py:302 mailbox_manager/models.py:302
msgid "email"
msgstr ""
#: build/lib/mailbox_manager/models.py:304 mailbox_manager/models.py:304
#: build/lib/mailbox_manager/models.py:308 mailbox_manager/models.py:308
msgid "Mailbox"
msgstr ""
#: build/lib/mailbox_manager/models.py:305 mailbox_manager/models.py:305
#: build/lib/mailbox_manager/models.py:309 mailbox_manager/models.py:309
msgid "Mailboxes"
msgstr ""
#: build/lib/mailbox_manager/models.py:331 mailbox_manager/models.py:331
#: 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:369 mailbox_manager/models.py:369
#: build/lib/mailbox_manager/models.py:408 mailbox_manager/models.py:408
msgid "Mail domain invitation"
msgstr ""
#: build/lib/mailbox_manager/models.py:370 mailbox_manager/models.py:370
#: build/lib/mailbox_manager/models.py:409 mailbox_manager/models.py:409
msgid "Mail domain invitations"
msgstr ""
#: build/lib/mailbox_manager/models.py:382 mailbox_manager/models.py:382
#: 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/utils/dimail.py:268
#: mailbox_manager/utils/dimail.py:268
#: 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"
msgstr ""
#: build/lib/mailbox_manager/utils/dimail.py:279
#: mailbox_manager/utils/dimail.py:279
#: build/lib/mailbox_manager/utils/dimail.py:307
#: mailbox_manager/utils/dimail.py:307
msgid "Your password has been updated"
msgstr ""
+82 -59
View File
@@ -2,8 +2,8 @@ msgid ""
msgstr ""
"Project-Id-Version: lasuite-people\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-06-19 15:31+0000\n"
"PO-Revision-Date: 2025-06-30 10:05\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:53 core/models.py:250 core/models.py:364
#: core/models.py:510 core/models.py:1121 mailbox_manager/models.py:53
#: 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"
@@ -270,11 +270,11 @@ msgstr "Cet utilisateur est déjà dans cette organisation."
#: build/lib/core/models.py:761 core/models.py:761
msgid "external_id"
msgstr ""
msgstr "ID externe"
#: build/lib/core/models.py:762 core/models.py:762
msgid "Team external UUID for synchronization with external systems"
msgstr ""
msgstr "UUID externe de l'équipe pour la synchronisation avec des systèmes externes"
#: build/lib/core/models.py:787 core/models.py:787
msgid "is visible for all SP"
@@ -361,90 +361,101 @@ msgstr "Compte de service"
msgid "Account services"
msgstr "Comptes de service"
#: build/lib/mailbox_manager/admin.py:17 mailbox_manager/admin.py:17
#: 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:35 mailbox_manager/admin.py:35
#: 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:42 mailbox_manager/admin.py:42
#: 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:49 mailbox_manager/admin.py:49
#: 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:54 mailbox_manager/admin.py:54
#: 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:71 mailbox_manager/admin.py:71
#: 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:84 mailbox_manager/admin.py:84
#: 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:85 mailbox_manager/admin.py:85
#: 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:87 mailbox_manager/admin.py:87
#: 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:94 mailbox_manager/admin.py:94
#: 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:102 mailbox_manager/admin.py:102
#: 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:107 mailbox_manager/admin.py:107
#: 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:121 mailbox_manager/admin.py:121
#: 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:127 mailbox_manager/admin.py:127
#: 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:133 mailbox_manager/admin.py:133
#: 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:138 mailbox_manager/admin.py:138
#: 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:154 mailbox_manager/admin.py:154
#: 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:160 mailbox_manager/admin.py:160
#: 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:166 mailbox_manager/admin.py:166
#: 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,97 +487,109 @@ msgstr "Désactivé"
msgid "Action required"
msgstr "Action requise"
#: build/lib/mailbox_manager/models.py:32 mailbox_manager/models.py:32
#: 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:37 mailbox_manager/models.py:37
#: 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:42 mailbox_manager/models.py:42
#: 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:68 mailbox_manager/models.py:68
#: 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:72 mailbox_manager/models.py:72
#: 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:73 mailbox_manager/models.py:73
#: 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:78 mailbox_manager/models.py:78
#: build/lib/mailbox_manager/models.py:80 mailbox_manager/models.py:80
msgid "expected config"
msgstr "configuration attendue"
#: build/lib/mailbox_manager/models.py:79 mailbox_manager/models.py:79
#: 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:84 mailbox_manager/models.py:84
#: 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:85 mailbox_manager/models.py:85
#: 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:199 mailbox_manager/models.py:199
#: 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:200 mailbox_manager/models.py:200
#: 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:273 mailbox_manager/models.py:273
#: build/lib/mailbox_manager/models.py:277 mailbox_manager/models.py:277
msgid "local_part"
msgstr "local_part"
#: build/lib/mailbox_manager/models.py:287 mailbox_manager/models.py:287
#: 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:298 mailbox_manager/models.py:298
#: build/lib/mailbox_manager/models.py:302 mailbox_manager/models.py:302
msgid "email"
msgstr "email"
#: build/lib/mailbox_manager/models.py:304 mailbox_manager/models.py:304
#: build/lib/mailbox_manager/models.py:308 mailbox_manager/models.py:308
msgid "Mailbox"
msgstr "Boîte mail"
#: build/lib/mailbox_manager/models.py:305 mailbox_manager/models.py:305
#: build/lib/mailbox_manager/models.py:309 mailbox_manager/models.py:309
msgid "Mailboxes"
msgstr "Boîtes mail"
#: build/lib/mailbox_manager/models.py:331 mailbox_manager/models.py:331
#: 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:369 mailbox_manager/models.py:369
#: 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:370 mailbox_manager/models.py:370
#: 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:382 mailbox_manager/models.py:382
#: 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/utils/dimail.py:268
#: mailbox_manager/utils/dimail.py:268
#: 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"
msgstr "Informations sur votre nouvelle boîte mail"
#: build/lib/mailbox_manager/utils/dimail.py:279
#: mailbox_manager/utils/dimail.py:279
#: build/lib/mailbox_manager/utils/dimail.py:307
#: mailbox_manager/utils/dimail.py:307
msgid "Your password has been updated"
msgstr "Votre mot de passe a été mis à jour"
+58 -5
View File
@@ -10,9 +10,6 @@ from requests import exceptions
from mailbox_manager import enums, models
from mailbox_manager.utils.dimail import DimailAPIClient
# Prevent Ruff complaining about mark_safe below
# ruff: noqa: S308
@admin.action(description=_("Import emails from dimail"))
def sync_mailboxes_from_dimail(modeladmin, request, queryset): # pylint: disable=unused-argument
@@ -51,6 +48,51 @@ def sync_mailboxes_from_dimail(modeladmin, request, queryset): # pylint: disabl
)
@admin.action(description=_("Import aliases from dimail"))
def sync_aliases_from_dimail(modeladmin, request, queryset): # pylint: disable=unused-argument
"""
Admin action to import existing aliases from dimail.
Checks alias is not a duplicate and that usernames don't clash with existing mailboxes.
"""
excluded_domains = []
client = DimailAPIClient()
for domain in queryset:
if domain.status != enums.MailDomainStatusChoices.ENABLED:
excluded_domains.append(domain.name)
continue
try:
imported_aliases = client.import_aliases(domain)
except exceptions.HTTPError as err:
messages.error(
request,
_("Synchronisation failed for %(domain)s with message: %(err)s")
% {"domain": domain.name, "err": err},
)
else:
messages.success(
request,
_(
"Synchronisation succeed for %(domain)s.\
Imported %(count_imported)s aliases: %(aliases)s"
)
% {
"domain": domain.name,
"count_imported": len(imported_aliases),
"aliases": ", ".join(imported_aliases),
},
)
if excluded_domains:
messages.warning(
request,
_("Sync require enabled domains. Excluded domains: %(domains)s")
% {"domains": ", ".join(excluded_domains)},
)
@admin.action(description=_("Check and update status from dimail"))
def fetch_domain_status_from_dimail(modeladmin, request, queryset): # pylint: disable=unused-argument
"""Admin action to check domain health with dimail and update domain status."""
@@ -194,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,
@@ -207,8 +250,8 @@ class MailboxAdmin(UserAdmin):
list_display = ("__str__", "domain", "status", "updated_at")
list_filter = ("status",)
search_fields = ("local_part", "domain__name")
readonly_fields = ["updated_at", "local_part", "domain"]
search_fields = ("local_part", "domain__name", "first_name", "last_name")
readonly_fields = ["updated_at"]
fieldsets = None
add_fieldsets = (
@@ -267,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"]
@@ -3,6 +3,8 @@
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
@@ -29,7 +31,6 @@ class MailboxSerializer(serializers.ModelSerializer):
"secondary_email",
"status",
]
# everything is actually read-only as we do not allow update for now
read_only_fields = ["id", "status"]
def create(self, validated_data):
@@ -45,12 +46,21 @@ class MailboxSerializer(serializers.ModelSerializer):
"password": make_password(None), # generate an unusable password
}
)
if mailbox.domain.status == enums.MailDomainStatusChoices.ENABLED:
client = DimailAPIClient()
if validated_data["domain"].status == enums.MailDomainStatusChoices.ENABLED:
# send new mailbox request to dimail
response = client.create_mailbox(mailbox, self.context["request"].user.sub)
client = DimailAPIClient()
try:
response = client.create_mailbox(
mailbox, self.context["request"].user.sub
)
except django_exceptions.ValidationError as exc:
mailbox.delete()
raise exc
mailbox.status = enums.MailDomainStatusChoices.ENABLED
mailbox_data = response.json()
mailbox.set_password(mailbox_data["password"])
mailbox.save()
if mailbox.secondary_email:
@@ -67,10 +77,25 @@ class MailboxSerializer(serializers.ModelSerializer):
mailbox,
)
# actually save mailbox on our database
return mailbox
class MailboxUpdateSerializer(MailboxSerializer):
"""A more restrictive serializer when updating mailboxes"""
class Meta:
model = models.Mailbox
fields = [
"id",
"first_name",
"last_name",
"local_part",
"secondary_email",
"status",
]
read_only_fields = ("id", "local_part", "status")
class MailDomainSerializer(serializers.ModelSerializer):
"""Serialize mail domain."""
@@ -203,23 +228,10 @@ class MailDomainAccessSerializer(serializers.ModelSerializer):
"You must set a domain slug in kwargs to create a new domain access."
) from exc
try:
access = authenticated_user.mail_domain_accesses.get(
domain__slug=domain_slug
)
except models.MailDomainAccess.DoesNotExist as exc:
raise exceptions.PermissionDenied(
"You are not allowed to manage accesses for this domain."
) from exc
# Authenticated user must be owner or admin of current domain to set new roles
if access.role not in [
enums.MailDomainRoleChoices.OWNER,
enums.MailDomainRoleChoices.ADMIN,
]:
raise exceptions.PermissionDenied(
"You are not allowed to manage accesses for this domain."
)
# we're sure that an access exists. otherwise it would have failed at permissions.
access = authenticated_user.mail_domain_accesses.get(
domain__slug=domain_slug
)
# only an owner can set an owner role to another user
if (
@@ -277,11 +289,41 @@ class MailDomainInvitationSerializer(serializers.ModelSerializer):
) from exc
domain = models.MailDomain.objects.get(slug=domain_slug)
if not domain.get_abilities(user)["manage_accesses"]:
raise exceptions.PermissionDenied(
"You are not allowed to manage invitations for this domain."
)
attrs["domain"] = domain
attrs["issuer"] = user
return attrs
class AliasSerializer(serializers.ModelSerializer):
"""Serialize aliases."""
domain = MailDomainSerializer(default="")
class Meta:
model = models.Alias
fields = [
"id",
"local_part",
"destination",
"domain",
]
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):
"""
Override create function to fire a request to dimail on alias creation.
"""
if validated_data["domain"].status == enums.MailDomainStatusChoices.ENABLED:
alias = models.Alias(**validated_data)
# send new alias request to dimail
client = DimailAPIClient()
client.create_alias(alias, self.context["request"].user.sub)
return super().create(validated_data)
return None
@@ -1,8 +1,9 @@
"""API endpoints"""
from django.db.models import Q, Subquery
from django.http import Http404
from rest_framework import exceptions, filters, mixins, viewsets
from rest_framework import exceptions, filters, mixins, status, viewsets
from rest_framework.decorators import action
from rest_framework.response import Response
@@ -40,7 +41,10 @@ class MailDomainViewSet(
Fetch domain status and expected config from dimail.
"""
permission_classes = [permissions.AccessPermission]
permission_classes = [
permissions.DomainPermission,
permissions.DomainResourcePermission,
]
serializer_class = serializers.MailDomainSerializer
filter_backends = [filters.OrderingFilter]
ordering_fields = ["created_at", "name"]
@@ -112,7 +116,7 @@ class MailDomainAccessViewSet(
Delete targeted domain access
"""
permission_classes = [permissions.MailDomainAccessRolePermission]
permission_classes = [permissions.DomainResourcePermission]
serializer_class = serializers.MailDomainAccessSerializer
filter_backends = [filters.OrderingFilter]
ordering_fields = ["role", "user__email", "user__name"]
@@ -228,9 +232,11 @@ class MailDomainAccessViewSet(
class MailBoxViewSet(
viewsets.GenericViewSet,
mixins.CreateModelMixin,
mixins.ListModelMixin,
viewsets.GenericViewSet,
mixins.UpdateModelMixin,
mixins.RetrieveModelMixin,
):
"""MailBox ViewSet
@@ -252,9 +258,15 @@ class MailBoxViewSet(
POST /api/<version>/mail-domains/<domain_slug>/mailboxes/<mailbox_id>/reset/
Send a request to mail-provider to reset password.
PUT /api/<version>/mail-domains/<domain_slug>/mailboxes/<mailbox_id>/
Send a request to update mailbox. Cannot modify domain or local_part.
PATCH /api/<version>/mail-domains/<domain_slug>/mailboxes/<mailbox_id>/
Send a request to partially update mailbox. Cannot modify domain or local_part.
"""
permission_classes = [permissions.MailBoxPermission]
permission_classes = [permissions.DomainResourcePermission]
serializer_class = serializers.MailboxSerializer
filter_backends = [filters.OrderingFilter]
ordering = ["-created_at"]
@@ -267,6 +279,32 @@ class MailBoxViewSet(
return self.queryset.filter(domain__slug=domain_slug)
return self.queryset
def get_serializer_context(self):
"""Extra context provided to the serializer class."""
context = super().get_serializer_context()
context["domain_slug"] = self.kwargs["domain_slug"]
return context
def get_permissions(self):
"""Add a specific permission for domain viewers to update their own mailbox."""
if self.action in ["list", "retrieve"]:
permission_classes = [permissions.DomainPermission]
elif self.action in ["update", "partial_update"]:
permission_classes = [
permissions.DomainResourcePermission
| permissions.IsMailboxOwnerPermission
]
else:
return super().get_permissions()
return [permission() for permission in permission_classes]
def get_serializer_class(self):
"""Chooses list or detail serializer according to the action."""
if self.action in {"update", "partial_update"}:
return serializers.MailboxUpdateSerializer
return self.serializer_class
def perform_create(self, serializer):
"""Create new mailbox."""
domain_slug = self.kwargs.get("domain_slug", "")
@@ -310,6 +348,7 @@ class MailDomainInvitationViewset(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.RetrieveModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""API ViewSet for user invitations to domain management.
@@ -327,10 +366,13 @@ 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"
permission_classes = [permissions.AccessPermission]
permission_classes = [permissions.DomainResourcePermission]
queryset = (
models.MailDomainInvitation.objects.all()
.select_related("domain")
@@ -367,3 +409,102 @@ class MailDomainInvitationViewset(
)
return queryset
class AliasViewSet(
mixins.CreateModelMixin,
mixins.ListModelMixin,
mixins.DestroyModelMixin,
viewsets.GenericViewSet,
):
"""API ViewSet for aliases.
GET /api/<version>/mail-domains/<domain_slug>/aliases/
Return list of aliases related to that domain
POST /api/<version>/mail-domains/<domain_slug>/aliases/ with expected data:
- local_part: str
- destination: str
Return a newly created alias
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 = "pk"
permission_classes = [permissions.DomainResourcePermission]
serializer_class = serializers.AliasSerializer
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."""
context = super().get_serializer_context()
context["domain_slug"] = self.kwargs["domain_slug"]
return context
def get_queryset(self):
"""Return the queryset according to the action."""
queryset = super().get_queryset()
queryset = queryset.filter(domain__slug=self.kwargs["domain_slug"])
if self.action == "list":
# Determine which role the logged-in user has in the domain
user_role_query = models.MailDomainAccess.objects.filter(
user=self.request.user, domain__slug=self.kwargs["domain_slug"]
).values("role")
queryset = (
queryset
# Abilities are computed based on logged-in user's role and
# the user role on each domain access
.annotate(user_role=Subquery(user_role_query)).distinct()
)
return queryset
def destroy(self, request, *args, **kwargs):
"""
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(
"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)
+37 -13
View File
@@ -1,11 +1,40 @@
"""Permission handlers for the People mailbox manager app."""
from core.api import permissions as core_permissions
from django.shortcuts import get_object_or_404
from rest_framework import permissions
from core.api.permissions import IsAuthenticated
from mailbox_manager import models
class AccessPermission(core_permissions.IsAuthenticated):
class DomainPermission(IsAuthenticated):
"""Permission class to manage mailboxes and aliases for a mail domain"""
def has_permission(self, request, view):
"""Check permission based on domain."""
if not super().has_permission(request, view):
return False
if not view.kwargs.get("domain_slug"):
return True
domain = get_object_or_404(
models.MailDomain,
slug=view.kwargs.get("domain_slug", ""),
accesses__user=request.user,
)
abilities = domain.get_abilities(request.user)
if request.method.lower() == "delete":
return abilities.get("manage_accesses", False)
return abilities.get(request.method.lower(), False)
class DomainResourcePermission(DomainPermission):
"""Permission class for access objects."""
def has_object_permission(self, request, view, obj):
@@ -14,20 +43,15 @@ class AccessPermission(core_permissions.IsAuthenticated):
return abilities.get(request.method.lower(), False)
class MailBoxPermission(core_permissions.IsAuthenticated):
"""Permission class to manage mailboxes for a mail domain"""
class IsMailboxOwnerPermission(permissions.BasePermission):
"""Authorize update for domain viewers on their own mailbox."""
def has_permission(self, request, view):
"""Check permission based on domain."""
"""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(request.method.lower(), False)
class MailDomainAccessRolePermission(core_permissions.IsAuthenticated):
"""Permission class to manage mailboxes for a mail domain"""
return abilities["get"]
def has_object_permission(self, request, view, obj):
"""Check permission for a given object."""
abilities = obj.get_abilities(request.user)
return abilities.get(request.method.lower(), False)
"""If the user is trying to update their own mailbox."""
return obj.get_email() == request.user.email
+2 -1
View File
@@ -15,4 +15,5 @@ class MailboxManagerConfig(AppConfig):
"""
Import signals when the app is ready.
"""
import mailbox_manager.signals # pylint: disable=import-outside-toplevel, unused-import
# pylint: disable=import-outside-toplevel, unused-import
import mailbox_manager.signals # noqa: PLC0415
+11
View File
@@ -98,3 +98,14 @@ class MailDomainInvitationFactory(factory.django.DjangoModelFactory):
[role[0] for role in enums.MailDomainRoleChoices.choices]
)
issuer = factory.SubFactory(core_factories.UserFactory)
class AliasFactory(factory.django.DjangoModelFactory):
"""A factory to create aliases."""
class Meta:
model = models.Alias
domain = factory.SubFactory(MailDomainEnabledFactory)
local_part = factory.Faker("word")
destination = factory.Faker("email")
@@ -0,0 +1,25 @@
# Generated by Django 5.2.5 on 2025-09-08 12:41
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0025_alter_mailbox_secondary_email'),
]
operations = [
migrations.AlterUniqueTogether(
name='mailbox',
unique_together=set(),
),
migrations.AddConstraint(
model_name='mailbox',
constraint=models.UniqueConstraint(fields=('local_part', 'domain'), name='unique_username'),
),
migrations.AddConstraint(
model_name='mailbox',
constraint=models.UniqueConstraint(fields=('first_name', 'last_name', 'domain'), name='unique_ox_display_name'),
),
]
@@ -0,0 +1,22 @@
# Generated by Django 5.2.7 on 2025-10-21 15:49
import django.db.models.functions.text
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0026_alter_mailbox_unique_together_and_more'),
]
operations = [
migrations.RemoveConstraint(
model_name='mailbox',
name='unique_ox_display_name',
),
migrations.AddConstraint(
model_name='mailbox',
constraint=models.UniqueConstraint(django.db.models.functions.text.Lower('first_name'), django.db.models.functions.text.Lower('last_name'), models.F('domain'), name='unique_ox_display_name', violation_error_message='Mailbox with this First name, Last name and Domain already exists.'),
),
]
@@ -0,0 +1,33 @@
# Generated by Django 5.2.6 on 2025-10-13 12:55
import django.db.models.deletion
import uuid
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('mailbox_manager', '0027_remove_mailbox_unique_ox_display_name_and_more'),
]
operations = [
migrations.CreateModel(
name='Alias',
fields=[
('id', models.UUIDField(default=uuid.uuid4, editable=False, help_text='primary key for the record as UUID', primary_key=True, serialize=False, verbose_name='id')),
('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.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={
'verbose_name': 'Alias',
'verbose_name_plural': 'Aliases',
'db_table': 'people_aliases',
'ordering': ['-created_at'],
'unique_together': {('local_part', 'destination')},
},
),
]
@@ -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'),
),
]
+99 -1
View File
@@ -9,7 +9,9 @@ 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
from django.utils.text import slugify
from django.utils.translation import get_language, gettext, override
@@ -88,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()
@@ -303,7 +307,23 @@ class Mailbox(AbstractBaseUser, BaseModel):
db_table = "people_mail_box"
verbose_name = _("Mailbox")
verbose_name_plural = _("Mailboxes")
unique_together = ("local_part", "domain")
constraints = [
models.UniqueConstraint(
fields=["local_part", "domain"], name="unique_username"
),
models.UniqueConstraint(
Lower("first_name"),
Lower("last_name"),
"domain",
name="unique_ox_display_name",
violation_error_message="Mailbox with this First name, \
Last name and Domain already exists.",
),
# Display name in OpenXChange must be unique
# To avoid sending failing requests to dimail,
# we impose uniqueness here too
# And compare to lowercase to enforce case-insensitive uniqueness
]
ordering = ["-created_at"]
def __str__(self):
@@ -341,6 +361,25 @@ class Mailbox(AbstractBaseUser, BaseModel):
"""Return the email address of the mailbox."""
return f"{self.local_part}@{self.domain.name}"
def get_abilities(self, user):
"""Compute and return abilities for a given user."""
role = user.mail_domain_accesses.get(domain=self.domain).role
is_owner_or_admin = role in [
MailDomainRoleChoices.OWNER,
MailDomainRoleChoices.ADMIN,
]
is_self = self.get_email() == user.email
return {
"get": bool(role),
"post": is_owner_or_admin,
"patch": is_owner_or_admin or is_self,
"put": is_owner_or_admin or is_self,
"delete": False,
}
class MailDomainInvitation(BaseInvitation):
"""User invitation to mail domains."""
@@ -416,3 +455,62 @@ class MailDomainInvitation(BaseInvitation):
"patch": False,
"put": False,
}
class Alias(BaseModel):
"""Model for aliases."""
local_part = models.CharField(max_length=100, 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,
related_name="aliases",
null=False,
blank=False,
)
class Meta:
db_table = "people_aliases"
verbose_name = _("Alias")
verbose_name_plural = _("Aliases")
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}"
def get_abilities(self, user):
"""Compute and return abilities for a given user. Admin and owners can
edit aliases, but also viewer if the alias points to their email."""
try:
role = user.mail_domain_accesses.get(domain=self.domain).role
except (MailDomainAccess.DoesNotExist, IndexError):
role = None
is_owner_or_admin = role in [
MailDomainRoleChoices.OWNER,
MailDomainRoleChoices.ADMIN,
]
is_self = self.destination == user.email
return {
"get": bool(role),
"post": is_owner_or_admin,
"patch": False,
"put": False,
"delete": is_owner_or_admin or is_self,
}
@@ -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
@@ -0,0 +1,212 @@
"""
Tests for aliases API endpoint.
Focus on "create" 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_create__anonymous():
"""Anonymous user should not create aliases"""
domain = factories.MailDomainEnabledFactory()
response = APIClient().post(
f"/api/v1.0/mail-domains/{domain.slug}/aliases/",
{"whatever": "this should not be updated"},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
assert not models.Alias.objects.exists()
def test_api_aliases_create__no_access_forbidden_not_found():
"""Unrelated user not having domain permission should not create aliases."""
domain = factories.MailDomainEnabledFactory()
client = APIClient()
client.force_login(core_factories.UserFactory())
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/aliases/",
{"local_part": "intrusive", "destination": "intrusive@mail.com"},
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert not models.Alias.objects.exists()
def test_api_aliases_create__viewer_forbidden():
"""Domain viewers should not create aliases."""
domain = factories.MailDomainEnabledFactory()
access = factories.MailDomainAccessFactory(role="viewer", domain=domain)
client = APIClient()
client.force_login(access.user)
response = client.post(
f"/api/v1.0/mail-domains/{domain.slug}/aliases/",
{"local_part": "intrusive", "destination": "intrusive@mail.com"},
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert not models.Alias.objects.exists()
@responses.activate
def test_api_aliases_create__duplicate_forbidden():
"""Cannot create alias if existing alias same domain + local part + destination."""
access = factories.MailDomainAccessFactory(
role="owner", domain=factories.MailDomainEnabledFactory()
)
existing_alias = factories.AliasFactory(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": existing_alias.local_part,
"destination": existing_alias.destination,
},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert models.Alias.objects.filter(domain=access.domain).count() == 1
@responses.activate
def test_api_aliases_create__async_alias_bad_request(dimail_token_ok):
"""
If People fall out of sync with dimail, return a clear error if alias cannot be created
because it already exists on dimail.
"""
access = factories.MailDomainAccessFactory(
role="owner", domain=factories.MailDomainEnabledFactory()
)
client = APIClient()
client.force_login(access.user)
# Mock dimail response
responses.post(
re.compile(r".*/aliases/"),
json={"detail": "Alias already exists"},
status=status.HTTP_409_CONFLICT,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/",
{
"local_part": "already_existing_alias",
"destination": "someone@outsidedomain.com",
},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
assert response.json() == {
"NON_FIELD_ERRORS": [
"Alias already exists. Your domain is out of sync, please contact our support."
]
}
assert not models.Alias.objects.exists()
@responses.activate
@pytest.mark.parametrize(
"role",
[enums.MailDomainRoleChoices.OWNER, enums.MailDomainRoleChoices.ADMIN],
)
def test_api_aliases_create__admins_ok(role, dimail_token_ok):
"""Domain admins should be able to create aliases."""
access = factories.MailDomainAccessFactory(role=role)
client = APIClient()
client.force_login(access.user)
# Prepare responses
# token response in fixtures
responses.post(
re.compile(rf".*/domains/{access.domain.name}/aliases/"),
json={
"username": "contact",
"domain": access.domain.name,
"destination": "someone@outsidedomain.com",
"allow_to_send": True,
},
status=status.HTTP_201_CREATED,
content_type="application/json",
)
response = client.post(
f"/api/v1.0/mail-domains/{access.domain.slug}/aliases/",
{"local_part": "contact", "destination": "someone@outsidedomain.com"},
)
assert response.status_code == status.HTTP_201_CREATED
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()
@@ -0,0 +1,139 @@
"""
Tests for aliases API endpoint in People's app mailbox_manager.
Focus on "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_delete__anonymous():
"""Anonymous user should not be able to delete aliases."""
alias_ = factories.AliasFactory()
response = APIClient().delete(
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
def test_api_aliases_delete__no_access_forbidden_not_found():
"""
Authenticated users should not be allowed to delete an alias in a
mail domain to which they are not related.
"""
authenticated_user = core_factories.UserFactory()
alias_ = factories.AliasFactory()
client = APIClient()
client.force_login(authenticated_user)
response = client.delete(
f"/api/v1.0/mail-domains/{alias_.domain.slug}/aliases/{alias_.pk}/"
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert models.Alias.objects.count() == 1
def test_api_aliases_delete__viewer_forbidden():
"""
Authenticated users should not be allowed to delete aliases for a
mail domain in which they are a simple viewer.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.VIEWER)]
)
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_.pk}/"
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert models.Alias.objects.count() == 1
@responses.activate
def test_api_aliases_delete__administrators_allowed(dimail_token_ok):
"""
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)
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/{alias_.pk}/"
)
assert response.status_code == status.HTTP_204_NO_CONTENT
assert models.Alias.objects.count() == 5
@responses.activate
def test_api_aliases_delete__404_out_of_sync(dimail_token_ok):
"""
Should return a clear message when dimail returns 404 not found.
"""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
users=[(authenticated_user, enums.MailDomainRoleChoices.ADMIN)]
)
alias_ = factories.AliasFactory(domain=mail_domain)
# Mock dimail response
responses.delete(
re.compile(r".*/aliases/"),
json={"detail": "Not found"},
status=status.HTTP_404_NOT_FOUND,
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_.pk}/"
)
assert response.status_code == status.HTTP_200_OK
assert (
response.json()
== "Domain out of sync with mailbox provider, please contact our support."
)
assert not models.Alias.objects.exists()
@@ -0,0 +1,65 @@
"""
Tests for aliases API endpoint in People's app mailbox_manager.
Focus on "list" action.
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories
pytestmark = pytest.mark.django_db
def test_api_aliases_list__anonymous():
"""Anonymous user should not be able to list aliases"""
domain = factories.MailDomainEnabledFactory()
factories.AliasFactory.create_batch(3, domain=domain)
response = APIClient().get(
f"/api/v1.0/mail-domains/{domain.slug}/aliases/",
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_aliases_list__no_access_forbidden_not_found():
"""User authenticated but not having domain permission should not list aliases."""
factories.MailDomainAccessFactory() # access to another domain
domain = factories.MailDomainEnabledFactory()
factories.AliasFactory.create_batch(3, domain=domain)
client = APIClient()
client.force_login(core_factories.UserFactory())
response = client.get(
f"/api/v1.0/mail-domains/{domain.slug}/aliases/",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.parametrize(
"role",
[
enums.MailDomainRoleChoices.OWNER,
enums.MailDomainRoleChoices.ADMIN,
enums.MailDomainRoleChoices.VIEWER,
],
)
def test_api_aliases_list__authorized_ok(role):
"""Domain viewers and admins should be able to list aliases."""
access = factories.MailDomainAccessFactory(role=role)
factories.AliasFactory.create_batch(2, local_part="support", domain=access.domain)
factories.AliasFactory.create_batch(3, domain=access.domain)
client = APIClient()
client.force_login(access.user)
response = client.get(
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
@@ -35,7 +35,7 @@ def test_api_domain_invitations__create__anonymous():
}
def test_api_domain_invitations__create__authenticated_outsider():
def test_api_domain_invitations__create__no_access_forbidden_not_found():
"""Users should not be permitted to send domain management invitations
for a domain they don't manage."""
user = core_factories.UserFactory()
@@ -52,7 +52,7 @@ def test_api_domain_invitations__create__authenticated_outsider():
invitation_values,
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.parametrize(
@@ -86,7 +86,7 @@ def test_api_domain_invitations__admin_should_create_invites(role):
assert email.subject == "[La Suite] Vous avez été invité(e) à rejoindre la Régie"
def test_api_domain_invitations__viewers_should_not_invite_to_manage_domains():
def test_api_domain_invitations__no_access_forbidden_not_found():
"""
Domain viewers should not be able to invite new domain managers.
"""
@@ -110,7 +110,7 @@ def test_api_domain_invitations__viewers_should_not_invite_to_manage_domains():
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You are not allowed to manage invitations for this domain."
"detail": "You do not have permission to perform this action."
}
@@ -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()
@@ -26,7 +26,7 @@ def test_api_domain_invitations__anonymous_user_should_not_retrieve_invitations(
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_domain_invitations__unrelated_user_should_not_retrieve_invitations():
def test_api_domain_invitations__no_access_forbidden_not_found():
"""
Authenticated unrelated users should not be able to retrieve invitations.
"""
@@ -40,8 +40,8 @@ def test_api_domain_invitations__unrelated_user_should_not_retrieve_invitations(
f"/api/v1.0/mail-domains/{invitation.domain.slug}/invitations/",
)
assert response.status_code == status.HTTP_200_OK
assert response.json()["count"] == 0
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No MailDomain matches the given query."}
def test_api_domain_invitations__domain_managers_should_list_invitations():
@@ -41,17 +41,14 @@ def test_api_mail_domains__create_name_unique():
"""
Creating domain should raise an error if already existing name.
"""
factories.MailDomainFactory(name="existing_domain.com")
user = core_factories.UserFactory()
existing_domain = factories.MailDomainFactory()
client = APIClient()
client.force_login(user)
client.force_login(core_factories.UserFactory())
response = client.post(
"/api/v1.0/mail-domains/",
{
"name": "existing_domain.com",
},
{"name": existing_domain.name},
)
assert response.status_code == status.HTTP_400_BAD_REQUEST
@@ -71,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(
{
@@ -84,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",
)
@@ -158,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",
)
@@ -216,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"],
@@ -2,7 +2,6 @@
Tests for MailDomains API endpoint in People's mailbox manager app. Focus on "fetch" action.
"""
import json
import re
import pytest
@@ -82,7 +81,7 @@ def test_api_mail_domains__fetch_from_dimail__viewer():
],
)
@responses.activate
def test_api_mail_domains__fetch_from_dimail(role):
def test_api_mail_domains__fetch_from_dimail_admin_successful(role):
"""
Authenticated users should be allowed to fetch a domain
from dimail if they are an owner or admin.
@@ -105,14 +104,12 @@ def test_api_mail_domains__fetch_from_dimail(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,
@@ -69,7 +69,7 @@ def test_api_mail_domains__retrieve_authenticated_unrelated():
@responses.activate
def test_api_mail_domains__retrieve_authenticated_related():
def test_api_mail_domains__retrieve_authenticated_related_successful():
"""
Authenticated users should be allowed to retrieve a domain
to which they have access.
@@ -35,7 +35,7 @@ def test_api_mail_domain__accesses_create_anonymous():
assert models.MailDomainAccess.objects.exists() is False
def test_api_mail_domain__accesses_create_authenticated_unrelated():
def test_api_mail_domain__accesses_create_no_access_forbidden_not_found():
"""
Authenticated users should not be allowed to create domain accesses for a domain to
which they are not related.
@@ -56,14 +56,14 @@ def test_api_mail_domain__accesses_create_authenticated_unrelated():
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {
"detail": "You are not allowed to manage accesses for this domain."
"detail": "No MailDomain matches the given query.",
}
assert not models.MailDomainAccess.objects.filter(user=other_user).exists()
def test_api_mail_domain__accesses_create_authenticated_viewer():
def test_api_mail_domain__accesses_create_viewer_forbidden():
"""Viewer of a mail domain should not be allowed to create mail domain accesses."""
authenticated_user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory(
@@ -85,7 +85,7 @@ def test_api_mail_domain__accesses_create_authenticated_viewer():
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You are not allowed to manage accesses for this domain."
"detail": "You do not have permission to perform this action.",
}
assert not models.MailDomainAccess.objects.filter(user=other_user).exists()
@@ -13,7 +13,7 @@ from mailbox_manager import enums, factories, models
pytestmark = pytest.mark.django_db
def test_api_mail_domain__accesses_delete_anonymous():
def test_api_domain_accesses_delete__anonymous_forbidden():
"""Anonymous users should not be allowed to destroy a mail domain access."""
access = factories.MailDomainAccessFactory()
@@ -25,7 +25,7 @@ def test_api_mail_domain__accesses_delete_anonymous():
assert models.MailDomainAccess.objects.count() == 1
def test_api_mail_domain__accesses_delete_authenticated():
def test_api_domain_accesses_delete__unrelated_notfound():
"""
Authenticated users should not be allowed to delete a mail domain access for a
mail domain to which they are not related.
@@ -39,11 +39,11 @@ def test_api_mail_domain__accesses_delete_authenticated():
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.status_code == status.HTTP_404_NOT_FOUND
assert models.MailDomainAccess.objects.count() == 1
def test_api_mail_domain__accesses_delete_viewer():
def test_api_domain_accesses_delete__viewer_forbidden():
"""
Authenticated users should not be allowed to delete a mail domain access for a
mail domain in which they are a simple viewer.
@@ -65,7 +65,7 @@ def test_api_mail_domain__accesses_delete_viewer():
assert models.MailDomainAccess.objects.filter(user=access.user).exists()
def test_api_mail_domain__accesses_delete_administrators():
def test_api_domain_accesses_delete__administrators_successful():
"""
Administrators of a mail domain should be allowed to delete accesses excepted owner accesses.
"""
@@ -89,7 +89,7 @@ def test_api_mail_domain__accesses_delete_administrators():
assert models.MailDomainAccess.objects.count() == 1
def test_api_mail_domain__accesses_delete_owners():
def test_api_domain_accesses_delete__owners_successful():
"""
An owner should be able to delete the mail domain access of another user including
a owner access.
@@ -114,7 +114,7 @@ def test_api_mail_domain__accesses_delete_owners():
assert models.MailDomainAccess.objects.count() == 1
def test_api_mail_domain__accesses_delete_owners_last_owner():
def test_api_domain_accesses_delete__last_owner_forbidden():
"""
It should not be possible to delete the last owner access from a mail domain
"""
@@ -27,8 +27,7 @@ def test_api_mail_domain__accesses_list_anonymous():
def test_api_mail_domain__accesses_list_authenticated_unrelated():
"""
Authenticated users should not be allowed to list mail_domain accesses for a mail_domain
to which they are not related.
User with no access should not be allowed to list domain accesses.
"""
user = core_factories.UserFactory()
mail_domain = factories.MailDomainFactory()
@@ -43,12 +42,9 @@ def test_api_mail_domain__accesses_list_authenticated_unrelated():
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
)
assert response.status_code == 200
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {
"count": 0,
"next": None,
"previous": None,
"results": [],
"detail": "No MailDomain matches the given query.",
}
@@ -178,8 +174,12 @@ def test_api_mail_domain__accesses_list_authenticated_constant_numqueries(
# related users :
# - query retrieving logged-in user for user_role annotation
# - count from pagination
# - filter on slug and domain on permission (to return 404 instead of 403 when no access)
# - count ?
# - distinct from viewset
with django_assert_num_queries(3):
with django_assert_num_queries(5):
client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
)
@@ -189,7 +189,7 @@ def test_api_mail_domain__accesses_list_authenticated_constant_numqueries(
factories.MailDomainAccessFactory(domain=mail_domain)
# num queries should still be the same
with django_assert_num_queries(3):
with django_assert_num_queries(5):
response = client.get(
f"/api/v1.0/mail-domains/{mail_domain.slug}/accesses/",
)
@@ -43,7 +43,7 @@ def test_api_mail_domain__accesses_retrieve_authenticated_unrelated():
f"/api/v1.0/mail-domains/{access.domain.slug}/accesses/{access.id!s}/",
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No MailDomainAccess matches the given query."}
assert response.json() == {"detail": "No MailDomain matches the given query."}
# Accesses related to another mail_domain should be excluded even if the user is related to it
for other_access in [
@@ -55,9 +55,7 @@ def test_api_mail_domain__accesses_retrieve_authenticated_unrelated():
)
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {
"detail": "No MailDomainAccess matches the given query."
}
assert response.json() == {"detail": "No MailDomain matches the given query."}
def test_api_mail_domain__accesses_retrieve_authenticated_related():
@@ -27,7 +27,7 @@ def test_api_mail_domain__accesses_update_anonymous():
assert access.role == enums.MailDomainRoleChoices.VIEWER
def test_api_mail_domain__accesses_update_authenticated_unrelated():
def test_api_mail_domain__accesses_update_no_access_forbidden_not_found():
"""
An authenticated user should not be allowed to update a mail domain access
for a mail_domain to which they are not related.
@@ -42,7 +42,7 @@ def test_api_mail_domain__accesses_update_authenticated_unrelated():
{"role": enums.MailDomainRoleChoices.ADMIN},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.status_code == status.HTTP_404_NOT_FOUND
access.refresh_from_db()
assert access.role == enums.MailDomainRoleChoices.VIEWER
@@ -30,9 +30,7 @@ def test_api_mail_domain__available_users_anonymous():
def test_api_mail_domain__available_users_forbidden():
"""Authenticated user without accesses on maildomain should not be able to see available
users.
"""
"""Users with no access should not be able to list available users."""
authenticated_user = core_factories.UserFactory()
client = APIClient()
client.force_login(authenticated_user)
@@ -40,7 +38,7 @@ def test_api_mail_domain__available_users_forbidden():
response = client.get(f"/api/v1.0/mail-domains/{maildomain.slug}/accesses/users/")
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.status_code == status.HTTP_404_NOT_FOUND
@pytest.mark.parametrize(
File diff suppressed because it is too large Load Diff
@@ -26,7 +26,7 @@ def test_api_mailboxes__disable_anonymous_forbidden():
assert models.Mailbox.objects.get().status == enums.MailboxStatusChoices.ENABLED
def test_api_mailboxes__disable_authenticated_failure():
def test_api_mailboxes__disable_no_access_forbidden_not_found():
"""Authenticated users should not be able to disable mailbox
without specific role on mail domain."""
user = core_factories.UserFactory()
@@ -39,7 +39,7 @@ def test_api_mailboxes__disable_authenticated_failure():
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/disable/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.status_code == status.HTTP_404_NOT_FOUND
assert models.Mailbox.objects.get().status == enums.MailboxStatusChoices.ENABLED
@@ -26,9 +26,8 @@ def test_api_mailboxes__enable_anonymous_forbidden():
assert models.Mailbox.objects.get().status == enums.MailboxStatusChoices.DISABLED
def test_api_mailboxes__enable_authenticated_failure():
"""Authenticated users should not be able to enable mailbox
without specific role on mail domain."""
def test_api_mailboxes__enable_unrelated_not_found():
"""Unrelated users should not be able to enable mailbox."""
user = core_factories.UserFactory()
client = APIClient()
@@ -39,7 +38,7 @@ def test_api_mailboxes__enable_authenticated_failure():
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/enable/",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.status_code == status.HTTP_404_NOT_FOUND
assert models.Mailbox.objects.get().status == enums.MailboxStatusChoices.DISABLED
@@ -25,8 +25,8 @@ def test_api_mailboxes__list_anonymous():
}
def test_api_mailboxes__list_authenticated():
"""Authenticated users should not be able to list mailboxes"""
def test_api_mailboxes__list_unrelated_not_found():
"""Unrelated users should not be able to list mailboxes"""
user = core_factories.UserFactory()
client = APIClient()
@@ -36,10 +36,8 @@ def test_api_mailboxes__list_authenticated():
factories.MailboxFactory.create_batch(2, domain=mail_domain)
response = client.get(f"/api/v1.0/mail-domains/{mail_domain.slug}/mailboxes/")
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.json() == {
"detail": "You do not have permission to perform this action."
}
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {"detail": "No MailDomain matches the given query."}
@pytest.mark.parametrize(
@@ -0,0 +1,190 @@
"""
Unit tests for the mailbox API
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories
pytestmark = pytest.mark.django_db
def test_api_mailboxes_patch__anonymous_forbidden():
"""Anonymous users should not be able to update a mailbox."""
mailbox = factories.MailboxFactory()
saved_secondary = mailbox.secondary_email
response = APIClient().patch(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
{"secondary_email": "updated@example.com"},
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
mailbox.refresh_from_db()
assert mailbox.secondary_email == saved_secondary
def test_api_mailboxes_patch__no_access_forbidden_not_found():
"""Authenticated but unauthoriezd users should not be able to update mailboxes."""
mailbox = factories.MailboxFactory()
saved_secondary = mailbox.secondary_email
client = APIClient()
client.force_login(core_factories.UserFactory())
response = client.patch(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.local_part}/",
{"secondary_email": "updated@example.com"},
format="json",
)
# permission denied at domain level
assert response.status_code == status.HTTP_404_NOT_FOUND
mailbox.refresh_from_db()
assert mailbox.secondary_email == saved_secondary
def test_api_mailboxes_patch__viewer_forbidden():
"""User having viewer access on a domain should not be able to update
anything on a mailbox that's not theirs."""
viewer = core_factories.UserFactory()
mailbox = factories.MailboxFactory()
factories.MailDomainAccessFactory(
user=viewer,
domain=mailbox.domain,
role=enums.MailDomainRoleChoices.VIEWER,
)
client = APIClient()
client.force_login(viewer)
# should not be able to update any field
# we only try one field as 403 is global in our implementation
old_value = mailbox.secondary_email
response = client.patch(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
{"secondary_email": "updated@example.com"},
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mailbox.refresh_from_db()
assert mailbox.secondary_email == old_value
def test_api_mailboxes_patch__unauthorized_no_mailbox():
"""No mailbox returns a 404."""
access = factories.MailDomainAccessFactory(role=enums.MailDomainRoleChoices.ADMIN)
client = APIClient()
client.force_login(access.user)
response = client.patch(
f"/api/v1.0/mail-domains/{access.domain.slug}/mailboxes/nonexistent_mailbox_pk/",
{"secondary_email": "updated@example.com"},
format="json",
)
# permission denied at domain level
# the existence of the mailbox is not checked
assert response.status_code == status.HTTP_404_NOT_FOUND
# UPDATABLE FIELDS : SECONDARY EMAIL FIRST NAME AND LAST NAME
@pytest.mark.parametrize(
"role",
["owner", "administrator"],
)
def test_api_mailboxes_patch__admins_can_update_mailboxes(role):
"""Domain owners and admins should be allowed to update secondary email on a mailbox"""
mailbox = factories.MailboxFactory()
user = core_factories.UserFactory()
factories.MailDomainAccessFactory(
user=user,
domain=mailbox.domain,
role=role,
)
client = APIClient()
client.force_login(user)
valid_new_values = {
"secondary_email": "updated_mail@validformat.com",
"first_name": "Marsha",
"last_name": "Johnson",
}
for field_name in ["first_name", "last_name", "secondary_email"]:
response = client.patch(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
{field_name: valid_new_values[field_name]},
format="json",
)
assert response.status_code == status.HTTP_200_OK
mailbox.refresh_from_db()
assert getattr(mailbox, field_name) == valid_new_values[field_name]
def test_api_mailboxes_patch__viewer_can_update_own_mailbox():
"""Domain owners and admins should be allowed to update secondary email on a mailbox"""
mailbox = factories.MailboxFactory()
user = core_factories.UserFactory(email=f"{mailbox.local_part}@{mailbox.domain}")
factories.MailDomainAccessFactory(
user=user,
domain=mailbox.domain,
role=enums.MailDomainRoleChoices.VIEWER,
)
client = APIClient()
client.force_login(user)
valid_new_values = {
"secondary_email": "updated_mail@validformat.com",
"first_name": "Marsha",
"last_name": "Johnson",
}
for field_name in ["first_name", "last_name", "secondary_email"]:
response = client.patch(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
{field_name: valid_new_values[field_name]},
format="json",
)
assert response.status_code == status.HTTP_200_OK
mailbox.refresh_from_db()
assert getattr(mailbox, field_name) == valid_new_values[field_name]
# DOMAIN AND LOCAL PART
@pytest.mark.parametrize(
"role",
["viewer", "owner", "administrator"],
)
def test_api_mailboxes_patch__no_updating_domain_or_local_part(role):
"""Domain and local parts should not be updated."""
mailbox = factories.MailboxFactory()
user = core_factories.UserFactory()
if role == "viewer":
# viewer has similar privileges as admins on own mailbox
user = core_factories.UserFactory(
email=f"{mailbox.local_part}@{mailbox.domain}"
)
access = factories.MailDomainAccessFactory(
user=user,
domain=mailbox.domain,
role=role,
)
client = APIClient()
client.force_login(user)
other_domain = factories.MailDomainEnabledFactory()
local_part = mailbox.local_part
response = client.patch(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
{"local_part": "new.local.part", "domain": other_domain.name},
format="json",
)
assert response.status_code == status.HTTP_200_OK # a 400 would be better
mailbox.refresh_from_db()
assert mailbox.local_part == local_part
assert mailbox.domain == access.domain
@@ -0,0 +1,192 @@
"""
Unit tests for the mailbox API
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import enums, factories
pytestmark = pytest.mark.django_db
# PUT expects all the object's updatable fields.
# We'll use this dict on all the following tests
NEW_VALUES = {
"secondary_email": "marsha.p@social.us",
"first_name": "Marsha",
"last_name": "Johnson",
}
def test_api_mailboxes_put__anonymous_forbidden():
"""Anonymous users should not be able to update a mailbox."""
mailbox = factories.MailboxFactory()
saved_secondary = mailbox.secondary_email
response = APIClient().put(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
NEW_VALUES,
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
mailbox.refresh_from_db()
assert mailbox.secondary_email == saved_secondary
def test_api_mailboxes_put__no_access_forbidden_not_found():
"""Authenticated but unauthoriezd users should not be able to update mailboxes."""
client = APIClient()
client.force_login(core_factories.UserFactory())
mailbox = factories.MailboxFactory()
saved_secondary = mailbox.secondary_email
response = client.put(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
NEW_VALUES,
format="json",
)
# permission denied at domain level
assert response.status_code == status.HTTP_404_NOT_FOUND
mailbox.refresh_from_db()
assert mailbox.secondary_email == saved_secondary
def test_api_mailboxes_put__unauthorized_no_mailbox():
"""Authenticated but unauthoriezd users should not be able to update mailboxes."""
client = APIClient()
client.force_login(core_factories.UserFactory())
domain = factories.MailDomainEnabledFactory()
response = client.put(
f"/api/v1.0/mail-domains/{domain.slug}/mailboxes/nonexistent_mailbox_pk/",
NEW_VALUES,
format="json",
)
# permission denied at domain level
# the existence of the mailbox is not checked
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_api_mailboxes_put__viewer_forbidden():
"""User having viewer access on a domain should not be able to update
anything on a mailbox that's not theirs."""
viewer = core_factories.UserFactory()
mailbox = factories.MailboxFactory()
factories.MailDomainAccessFactory(
user=viewer,
domain=mailbox.domain,
role=enums.MailDomainRoleChoices.VIEWER,
)
client = APIClient()
client.force_login(viewer)
# should not be able to update any field
# we only try one field as 403 is global in our implementation
old_value = mailbox.secondary_email
response = client.put(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
NEW_VALUES,
format="json",
)
assert response.status_code == status.HTTP_403_FORBIDDEN
mailbox.refresh_from_db()
assert mailbox.secondary_email == old_value
# UPDATABLE FIELDS : SECONDARY EMAIL, FIRST NAME AND LAST NAME
@pytest.mark.parametrize(
"role",
["owner", "administrator"],
)
def test_api_mailboxes_put__admins_can_update_mailboxes(role):
"""Domain owners and admins should be allowed to update secondary email on a mailbox"""
mailbox = factories.MailboxFactory()
user = core_factories.UserFactory()
factories.MailDomainAccessFactory(
user=user,
domain=mailbox.domain,
role=role,
)
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
NEW_VALUES,
format="json",
)
result = response.json()
assert response.status_code == status.HTTP_200_OK
mailbox.refresh_from_db()
assert result["first_name"] == NEW_VALUES["first_name"]
assert result["last_name"] == NEW_VALUES["last_name"]
assert result["secondary_email"] == NEW_VALUES["secondary_email"]
def test_api_mailboxes_put__viewer_can_update_own_mailbox():
"""Domain owners and admins should be allowed to update secondary email on a mailbox"""
mailbox = factories.MailboxFactory()
user = core_factories.UserFactory(email=f"{mailbox.local_part}@{mailbox.domain}")
factories.MailDomainAccessFactory(
user=user,
domain=mailbox.domain,
role=enums.MailDomainRoleChoices.VIEWER,
)
client = APIClient()
client.force_login(user)
response = client.put(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
NEW_VALUES,
format="json",
)
result = response.json()
assert response.status_code == status.HTTP_200_OK
mailbox.refresh_from_db()
assert result["first_name"] == NEW_VALUES["first_name"]
assert result["last_name"] == NEW_VALUES["last_name"]
assert result["secondary_email"] == NEW_VALUES["secondary_email"]
# DOMAIN AND LOCAL PART
@pytest.mark.parametrize(
"role",
["viewer", "owner", "administrator"],
)
def test_api_mailboxes_put__no_updating_domain_or_local_part(role):
"""Domain and local parts should not be updated."""
mailbox = factories.MailboxFactory()
user = core_factories.UserFactory()
if role == "viewer":
# viewer has similar privileges as admins on own mailbox
user = core_factories.UserFactory(
email=f"{mailbox.local_part}@{mailbox.domain}"
)
access = factories.MailDomainAccessFactory(
user=user,
domain=mailbox.domain,
role=role,
)
client = APIClient()
client.force_login(user)
other_domain = factories.MailDomainEnabledFactory()
old_local_part = mailbox.local_part
response = client.put(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/",
{**NEW_VALUES, "local_part": "new.local.part", "domain": other_domain.name},
format="json",
)
assert response.status_code == status.HTTP_200_OK # This should not be a 200
mailbox.refresh_from_db()
assert mailbox.local_part == old_local_part
assert mailbox.domain == access.domain
@@ -28,7 +28,7 @@ def test_api_mailboxes__reset_password_anonymous_unauthorized():
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_mailboxes__reset_password_unrelated_forbidden():
def test_api_mailboxes__reset_password_no_access_forbidden_not_found():
"""Authenticated users not managing the domain
should not be able to reset its mailboxes password."""
user = core_factories.UserFactory()
@@ -41,9 +41,9 @@ def test_api_mailboxes__reset_password_unrelated_forbidden():
response = client.post(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/reset_password/"
)
assert response.status_code == status.HTTP_403_FORBIDDEN
assert response.status_code == status.HTTP_404_NOT_FOUND
assert response.json() == {
"detail": "You do not have permission to perform this action."
"detail": "No MailDomain matches the given query.",
}
@@ -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(),
)
@@ -0,0 +1,68 @@
"""
Unit tests for the mailbox API
"""
import pytest
from rest_framework import status
from rest_framework.test import APIClient
from core import factories as core_factories
from mailbox_manager import factories
pytestmark = pytest.mark.django_db
def test_api_mailboxes__retrieve_anonymous_forbidden():
"""Anonymous users should not be able to retrieve a new mailbox via the API."""
mailbox = factories.MailboxFactory()
response = APIClient().get(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/"
)
assert response.status_code == status.HTTP_401_UNAUTHORIZED
def test_api_mailboxes__retrieve_no_access_forbidden_not_found():
"""Authenticated but unauthorized users should not be able to
retrieve mailbox. Response should be indistinguisable from normal 404"""
client = APIClient()
client.force_login(core_factories.UserFactory())
mailbox = factories.MailboxFactory()
response = client.get(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/"
)
assert response.status_code == status.HTTP_404_NOT_FOUND
# even if they have a mailbox on this domain
mailbox = factories.MailboxFactory()
client.force_login(core_factories.UserFactory(email=str(mailbox)))
response = client.get(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/"
)
assert response.status_code == status.HTTP_404_NOT_FOUND
def test_api_mailboxes__retrieve_authorized_ok():
"""Authorized users should be able to retrieve mailboxes."""
access = factories.MailDomainAccessFactory()
mailbox = factories.MailboxFactory(domain=access.domain)
client = APIClient()
client.force_login(access.user)
response = client.get(
f"/api/v1.0/mail-domains/{mailbox.domain.slug}/mailboxes/{mailbox.pk}/"
)
assert response.status_code == status.HTTP_200_OK
assert response.json() == {
"id": str(mailbox.id),
"first_name": mailbox.first_name,
"last_name": mailbox.last_name,
"local_part": mailbox.local_part,
"secondary_email": mailbox.secondary_email,
"status": mailbox.status,
}
@@ -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,
@@ -0,0 +1,35 @@
"""
Fixtures for mailbox manager tests
"""
import re
import pytest
import responses
from rest_framework import status
from mailbox_manager import factories
## DIMAIL RESPONSES
@pytest.fixture(name="dimail_token_ok")
def fixture_dimail_token_ok():
"""Mock dimail response when /token/ endpoit is given valid credentials."""
responses.get(
re.compile(r".*/token/"),
json={"access_token": "token", "token_type": "bearer"},
status=status.HTTP_200_OK,
content_type="application/json",
)
@pytest.fixture(name="mailbox_data")
def fixture_mailbox_data():
"""Provides valid mailbox data for tests."""
example = factories.MailboxFactory.build()
return {
"first_name": example.first_name,
"last_name": example.last_name,
"local_part": example.local_part,
"secondary_email": example.secondary_email,
}
+1 -11
View File
@@ -3,9 +3,8 @@
import json
## USERS
def response_user_created(user_sub):
"""mimic dimail response upon successful user creation."""
return json.dumps(
@@ -19,7 +18,6 @@ def response_user_created(user_sub):
## DOMAINS
CHECK_DOMAIN_BROKEN = {
"name": "example.fr",
"state": "broken",
@@ -289,13 +287,7 @@ 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.
Dimail expects a name but our names are ProConnect's uuids."""
@@ -303,8 +295,6 @@ def response_allows_created(user_name, domain_name):
## MAILBOXES
def response_mailbox_created(email_address):
"""mimic dimail response upon successful mailbox creation."""
return json.dumps({"email": email_address, "password": "password"})
@@ -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",
)
@@ -2,12 +2,10 @@
Unit tests for dimail client
"""
import json
# pylint: disable=W0613
import logging
import re
from email.errors import HeaderParseError, NonASCIILocalPartDefect
from logging import Logger
from unittest import mock
import pytest
import responses
@@ -21,14 +19,14 @@ from .fixtures.dimail import (
CHECK_DOMAIN_BROKEN_EXTERNAL,
CHECK_DOMAIN_BROKEN_INTERNAL,
CHECK_DOMAIN_OK,
TOKEN_OK,
response_mailbox_created,
)
pytestmark = pytest.mark.django_db
def test_dimail_synchronization__already_sync():
@responses.activate
def test_dimail_synchronization__already_sync(dimail_token_ok):
"""
No mailbox should be created when everything is already synced.
"""
@@ -39,35 +37,26 @@ def test_dimail_synchronization__already_sync():
assert pre_sync_mailboxes.count() == 3
dimail_client = DimailAPIClient()
with responses.RequestsMock() as rsps:
# Ensure successful response using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "dimail_people_token"}',
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.GET,
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
body=str(
[
{
"type": "mailbox",
"status": "broken",
"email": f"{mailbox.local_part}@{domain.name}",
"givenName": mailbox.first_name,
"surName": mailbox.last_name,
"displayName": f"{mailbox.first_name} {mailbox.last_name}",
}
for mailbox in pre_sync_mailboxes
]
),
status=status.HTTP_200_OK,
content_type="application/json",
)
imported_mailboxes = dimail_client.import_mailboxes(domain)
# Ensure successful response using "responses":
# token response in fixtures
responses.get(
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
json=[
{
"type": "mailbox",
"status": "broken",
"email": f"{mailbox.local_part}@{domain.name}",
"givenName": mailbox.first_name,
"surName": mailbox.last_name,
"displayName": f"{mailbox.first_name} {mailbox.last_name}",
}
for mailbox in pre_sync_mailboxes
],
status=status.HTTP_200_OK,
content_type="application/json",
)
imported_mailboxes = dimail_client.import_mailboxes(domain)
post_sync_mailboxes = models.Mailbox.objects.filter(domain=domain)
assert post_sync_mailboxes.count() == 3
@@ -75,97 +64,159 @@ def test_dimail_synchronization__already_sync():
assert set(models.Mailbox.objects.filter(domain=domain)) == set(pre_sync_mailboxes)
@mock.patch.object(Logger, "warning")
def test_dimail_synchronization__synchronize_mailboxes(mock_warning):
"""A mailbox existing solely on dimail should be synchronized
upon calling sync function on its domain"""
@responses.activate
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()
with responses.RequestsMock() as rsps:
# Ensure successful response using "responses":
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body='{"access_token": "dimail_people_token"}',
status=status.HTTP_200_OK,
content_type="application/json",
)
# Ensure successful response using "responses":
# 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}",
"givenName": "Admin",
"surName": "Context",
"displayName": "Context Admin",
}
mailbox_with_wrong_domain = {
"type": "mailbox",
"status": "broken",
"email": "johndoe@wrongdomain.com",
"givenName": "John",
"surName": "Doe",
"displayName": "John Doe",
}
mailbox_with_invalid_domain = {
"type": "mailbox",
"status": "broken",
"email": f"naw@ake@{domain.name}",
"givenName": "Joe",
"surName": "Doe",
"displayName": "Joe Doe",
}
mailbox_with_invalid_local_part = {
"type": "mailbox",
"status": "broken",
"email": f"obalmaské@{domain.name}",
"givenName": "Jean",
"surName": "Vang",
"displayName": "Jean Vang",
}
mailbox_existing_alias = {
"type": "mailbox",
"status": "broken",
"email": f"{existing_alias.local_part}@{domain.name}",
"givenName": "Support",
"surName": "email",
"displayName": "Support email",
}
mailbox_valid = {
"type": "mailbox",
"status": "broken",
"email": f"oxadmin@{domain.name}",
"givenName": "Admin",
"surName": "Context",
"displayName": "Context Admin",
}
mailbox_with_wrong_domain = {
"type": "mailbox",
"status": "broken",
"email": "johndoe@wrongdomain.com",
"givenName": "John",
"surName": "Doe",
"displayName": "John Doe",
}
mailbox_with_invalid_domain = {
"type": "mailbox",
"status": "broken",
"email": f"naw@ake@{domain.name}",
"givenName": "Joe",
"surName": "Doe",
"displayName": "Joe Doe",
}
mailbox_with_invalid_local_part = {
"type": "mailbox",
"status": "broken",
"email": f"obalmaské@{domain.name}",
"givenName": "Jean",
"surName": "Vang",
"displayName": "Jean Vang",
}
responses.get(
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_alias,
],
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.GET,
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
body=str(
[
mailbox_valid,
mailbox_with_wrong_domain,
mailbox_with_invalid_domain,
mailbox_with_invalid_local_part,
]
),
status=status.HTTP_200_OK,
content_type="application/json",
)
imported_mailboxes = dimail_client.import_mailboxes(domain)
imported_mailboxes = dimail_client.import_mailboxes(domain)
# 4 imports failed: oxadmin, wrong domain, HeaderParseError, NonASCIILocalPartDefect
assert len(caplog.records) == 5
log_messages = [record.message for record in caplog.records]
# 3 imports failed: wrong domain, HeaderParseError, NonASCIILocalPartDefect
assert mock_warning.call_count == 3
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
# 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"],
)
assert models.Mailbox.objects.count() == 2
assert imported_mailboxes == [
mailbox_valid["email"],
mailbox_existing_alias["email"],
]
# 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)
@responses.activate
def test_dimail_synchronization__synchronize_aliases(dimail_token_ok): # pylint: disable=unused-argument
"""Importing aliases from dimail should synchronize valid aliases
and log errors for invalid ones."""
alias = factories.AliasFactory()
dimail_client = DimailAPIClient()
mailbox = models.Mailbox.objects.get()
assert mailbox.local_part == "oxadmin"
assert mailbox.status == enums.MailboxStatusChoices.ENABLED
assert imported_mailboxes == [mailbox_valid["email"]]
existing_mailbox = factories.MailboxFactory(domain=alias.domain)
# Ensure successful response using "responses":
# token response in fixtures
incoming_aliases = [
{
"username": "contact",
"domain": alias.domain.name,
"destination": alias.destination, # same destination = ok
"allow_to_send": False,
},
{
"username": alias.local_part, # same username = ok
"domain": alias.domain.name,
"destination": "maheius.endorecles@somethingelse.com",
"allow_to_send": False,
},
{ # same username + same destination = big nono
"username": alias.local_part,
"domain": alias.domain.name,
"destination": alias.destination,
"allow_to_send": False,
},
{ # 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/"),
json=incoming_aliases,
status=status.HTTP_200_OK,
content_type="application/json",
)
imported_aliases = dimail_client.import_aliases(alias.domain)
assert len(imported_aliases) == 4
assert models.Alias.objects.count() == 5
@pytest.mark.parametrize(
@@ -183,10 +234,9 @@ def test_dimail__fetch_domain_status__switch_to_enabled(domain_status):
domain = factories.MailDomainFactory(status=domain_status)
body_content = CHECK_DOMAIN_OK.copy()
body_content["name"] = domain.name
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_content),
json=body_content,
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -221,10 +271,9 @@ def test_dimail__fetch_domain_status__switch_to_action_required(
domain = factories.MailDomainFactory(status=domain_status)
body_domain_broken = CHECK_DOMAIN_BROKEN_EXTERNAL.copy()
body_domain_broken["name"] = domain.name
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_domain_broken),
json=body_domain_broken,
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -238,10 +287,9 @@ def test_dimail__fetch_domain_status__switch_to_action_required(
# Now domain is OK again
body_domain_ok = CHECK_DOMAIN_OK.copy()
body_domain_ok["name"] = domain.name
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_domain_ok),
json=body_domain_ok,
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -267,18 +315,16 @@ def test_dimail__fetch_domain_status__switch_to_failed(domain_status):
# nothing can be done by support team, domain should be in failed
body_domain_broken = CHECK_DOMAIN_BROKEN_INTERNAL.copy()
body_domain_broken["name"] = domain.name
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_domain_broken),
json=body_domain_broken,
status=status.HTTP_200_OK,
content_type="application/json",
)
# the endpoint fix is called and still returns KO for internal checks
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/fix/"),
body=json.dumps(body_domain_broken),
json=body_domain_broken,
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -305,10 +351,9 @@ def test_dimail__fetch_domain_status__full_fix_scenario(domain_status):
# with all checks KO, domain should be in action required
body_domain_broken = CHECK_DOMAIN_BROKEN.copy()
body_domain_broken["name"] = domain.name
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_domain_broken),
json=body_domain_broken,
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -324,20 +369,18 @@ def test_dimail__fetch_domain_status__full_fix_scenario(domain_status):
# the fetch_domain_status call
body_domain_broken_internal = CHECK_DOMAIN_BROKEN_INTERNAL.copy()
body_domain_broken_internal["name"] = domain.name
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/check/"),
body=json.dumps(body_domain_broken_internal),
json=body_domain_broken_internal,
status=status.HTTP_200_OK,
content_type="application/json",
)
# the endpoint fix is called and returns OK. Hooray!
body_domain_ok = CHECK_DOMAIN_OK.copy()
body_domain_ok["name"] = domain.name
responses.add(
responses.GET,
responses.get(
re.compile(rf".*/domains/{domain.name}/fix/"),
body=json.dumps(body_domain_ok),
json=body_domain_ok,
status=status.HTTP_200_OK,
content_type="application/json",
)
@@ -348,7 +391,8 @@ def test_dimail__fetch_domain_status__full_fix_scenario(domain_status):
assert domain.last_check_details == body_domain_ok
def test_dimail__send_pending_mailboxes(caplog):
@responses.activate
def test_dimail__send_pending_mailboxes(caplog, dimail_token_ok):
"""Status of pending mailboxes should switch to "enabled"
when calling send_pending_mailboxes."""
caplog.set_level(logging.INFO)
@@ -365,22 +409,16 @@ def test_dimail__send_pending_mailboxes(caplog):
)
dimail_client = DimailAPIClient()
with responses.RequestsMock() as rsps:
rsps.add(
rsps.GET,
re.compile(r".*/token/"),
body=TOKEN_OK,
status=status.HTTP_200_OK,
content_type="application/json",
)
rsps.add(
rsps.POST,
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
body=response_mailbox_created(f"mock@{domain.name}"),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
dimail_client.send_pending_mailboxes(domain=domain)
# Ensure successful response using "responses":
# token response in fixtures
responses.post(
re.compile(rf".*/domains/{domain.name}/mailboxes/"),
body=response_mailbox_created(f"mock@{domain.name}"),
status=status.HTTP_201_CREATED,
content_type="application/json",
)
dimail_client.send_pending_mailboxes(domain=domain)
mailbox1.refresh_from_db()
mailbox2.refresh_from_db()
+5 -1
View File
@@ -29,7 +29,11 @@ maildomain_related_router.register(
viewsets.MailDomainInvitationViewset,
basename="invitations",
)
maildomain_related_router.register(
"aliases",
viewsets.AliasViewSet,
basename="aliases",
)
urlpatterns = [
path(
+261 -49
View File
@@ -2,7 +2,6 @@
"""A minimalist client to synchronize with mailbox provisioning API."""
import ast
import json
import smtplib
from email.errors import HeaderParseError, NonASCIILocalPartDefect
@@ -46,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.
@@ -83,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."""
@@ -118,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."""
@@ -131,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(
@@ -166,7 +165,36 @@ class DimailAPIClient:
"Permission denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
)
return self.raise_exception_for_unexpected_response(response)
# Dimail doesn't return a clear error for duplicate yet
# but a 500 internal error
if response.status_code == status.HTTP_500_INTERNAL_SERVER_ERROR:
try:
address = session.get(
f"{self.API_URL}/domains/{mailbox.domain.name}/address/{mailbox.local_part}/",
json=payload,
headers=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
if address.status_code == status.HTTP_200_OK:
primary = address.json()["ox_primary_email"]
raise exceptions.ValidationError(
{
"NON_FIELD_ERRORS": [
f"First name + last name combination already in use in this context : {primary}."
]
}
)
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."""
@@ -203,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."""
@@ -245,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(
@@ -271,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.
@@ -331,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,
)
@@ -344,43 +372,48 @@ class DimailAPIClient:
raise error
if response.status_code != status.HTTP_200_OK:
return self.raise_exception_for_unexpected_response(response)
dimail_mailboxes = ast.literal_eval(
response.content.decode("utf-8")
) # format output str to proper list
return self._raise_exception_for_unexpected_response(response)
dimail_mailboxes = response.json()
people_mailboxes = models.Mailbox.objects.filter(domain=domain)
imported_mailboxes = []
for dimail_mailbox in dimail_mailboxes:
if not dimail_mailbox["email"] in [
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
]:
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:
# 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
@@ -389,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,
)
@@ -401,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"""
@@ -413,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,
)
@@ -425,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."""
@@ -474,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.
@@ -491,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."""
@@ -616,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,
)
@@ -630,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"],
@@ -642,4 +675,183 @@ 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."""
payload = {
"user_name": alias.local_part,
"destination": alias.destination,
}
headers = self._get_headers()
try:
response = session.post(
f"{self.API_URL}/domains/{alias.domain.name}/aliases/",
json=payload,
headers=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
if response.status_code == status.HTTP_201_CREATED:
logger.info(
"User %s linked alias %s to a new email.",
request_user,
f"{alias.local_part}@{alias.domain}",
)
return response
if response.status_code == status.HTTP_403_FORBIDDEN:
logger.error(
"[DIMAIL] 403 Forbidden: you cannot access domain %s",
str(alias.domain),
)
raise exceptions.PermissionDenied(
"Permission denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
)
if response.status_code == status.HTTP_409_CONFLICT:
logger.error(
"[DIMAIL] Out of sync with dimail. Admin, please import aliases for domain %s",
str(alias.domain),
)
raise exceptions.ValidationError(
{
"NON_FIELD_ERRORS": [
"Alias already exists. Your domain is out of sync, please contact our support."
]
}
)
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()
try:
response = session.delete(
f"{self.API_URL}/domains/{alias.domain.name}/aliases/{alias.local_part}/{alias.destination}",
json={},
headers=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
if response.status_code == status.HTTP_204_NO_CONTENT:
logger.info(
"User %s removed destination %s from alias %s.",
request_user,
alias.destination,
f"{alias.local_part}@{alias.domain}",
)
return response
if response.status_code == status.HTTP_403_FORBIDDEN:
logger.error(
"[DIMAIL] 403 Forbidden: you cannot access domain %s",
str(alias.domain),
)
raise exceptions.PermissionDenied(
"Permission denied. Please check your MAIL_PROVISIONING_API_CREDENTIALS."
)
if response.status_code == status.HTTP_404_NOT_FOUND:
logger.error(
"[DIMAIL] 404, alias %s not found. Domain out of sync with dimail. Admin, please import aliases for domain %s",
str(alias.local_part),
str(alias.domain),
)
# we don't raise error because we actually want this alias to be deleted
return 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."""
try:
response = session.get(
f"{self.API_URL}/domains/{domain.name}/aliases/",
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
if response.status_code != status.HTTP_200_OK:
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)
]
imported_aliases = []
for incoming_alias in incoming_aliases:
if (
incoming_alias["username"],
incoming_alias["destination"],
) not in known_aliases:
try:
new_alias = models.Alias.objects.create(
local_part=incoming_alias["username"],
destination=incoming_alias["destination"],
domain=domain,
)
except (HeaderParseError, NonASCIILocalPartDefect) as err:
logger.warning(
"Import of alias %s to %s failed with error %s",
incoming_alias["username"],
incoming_alias["destination"],
err,
)
else:
imported_aliases.append(str(new_alias))
return imported_aliases
+2 -26
View File
@@ -74,7 +74,7 @@ class Base(Configuration):
You may also want to override default configuration by setting the following environment
variables:
* DJANGO_SENTRY_DSN
* SENTRY_DSN
* DB_NAME
* DB_HOST
* DB_PASSWORD
@@ -341,7 +341,7 @@ class Base(Configuration):
CORS_ALLOWED_ORIGIN_REGEXES = values.ListValue([])
# Sentry
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN")
SENTRY_DSN = values.Value(None, environ_name="SENTRY_DSN", environ_prefix=None)
# Easy thumbnails
THUMBNAIL_EXTENSION = "webp"
@@ -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",
@@ -1066,10 +1046,6 @@ class Production(Base):
},
},
}
SENTRY_DSN = values.Value(
"https://b72746c73d669421e7a8ccd3fab0fad2@sentry.incubateur.net/171",
environ_name="SENTRY_DSN",
)
class Feature(Production):
-13
View File
@@ -11,22 +11,9 @@ from core.plugins.registry import register_hook
from plugins.la_suite.hooks_utils.all_organizations import (
get_organization_name_and_metadata_from_siret,
)
from plugins.la_suite.hooks_utils.communes import CommuneCreation
@register_hook("organization_created")
def get_organization_name_and_metadata_from_siret_hook(organization):
"""After creating an organization, update the organization name & metadata."""
get_organization_name_and_metadata_from_siret(organization)
@register_hook("organization_created")
def commune_organization_created(organization):
"""After creating an organization, update the organization name."""
CommuneCreation().run_after_create(organization)
@register_hook("organization_access_granted")
def commune_organization_access_granted(organization_access):
"""After granting an organization access, check for needed domain access grant."""
CommuneCreation().run_after_grant_access(organization_access)
@@ -21,25 +21,45 @@ API_URL = "https://recherche-entreprises.api.gouv.fr/search?q={siret}"
def _get_organization_name_and_metadata_from_results(data, siret):
"""Return the organization name and metadata from the results of a SIRET search."""
org_metadata = {}
for result in data["results"]:
for organization in result["matching_etablissements"]:
if organization.get("siret") == siret:
org_metadata["is_public_service"] = result.get("complements", {}).get(
"est_service_public", False
)
org_metadata["is_commune"] = (
str(result.get("nature_juridique", "")) == "7210"
)
# Find matching organization
match = next(
(
(res, org)
for res in data.get("results", [])
for org in res.get("matching_etablissements", [])
if org.get("siret") == siret
),
None,
)
store_signs = organization.get("liste_enseignes") or []
if store_signs:
return store_signs[0].title(), org_metadata
if name := result.get("nom_raison_sociale"):
return name.title(), org_metadata
if not match:
logger.warning("No organization name found for SIRET %s", siret)
return None, {}
result, organization = match
# Extract metadata
is_commune = str(result.get("nature_juridique", "")) == "7210"
metadata = {
"is_public_service": result.get("complements", {}).get(
"est_service_public", False
),
"is_commune": is_commune,
}
# Extract name (priority: commune name > store signs > business name)
name = None
if is_commune:
name = result.get("siege", {}).get("libelle_commune")
if not name: # Fallback for non-communes OR if commune has no libelle_commune
store_signs = organization.get("liste_enseignes") or []
name = store_signs[0] if store_signs else result.get("nom_raison_sociale")
if name:
return name.title(), metadata
logger.warning("No organization name found for SIRET %s", siret)
return None, org_metadata
return None, metadata
def get_organization_name_and_metadata_from_siret(organization):
@@ -1,243 +0,0 @@
"""Organization related plugins."""
import logging
import re
from django.conf import settings
from django.utils.text import slugify
import requests
from requests.adapters import HTTPAdapter, Retry
from mailbox_manager.enums import MailDomainRoleChoices
from mailbox_manager.models import MailDomain, MailDomainAccess
logger = logging.getLogger(__name__)
class ApiCall:
"""Encapsulates a call to an external API"""
inputs: dict = {}
method: str = "GET"
base: str = ""
url: str = ""
params: dict = {}
headers: dict = {}
response_data = None
def execute(self):
"""Call the specified API endpoint with supplied parameters and record response"""
if self.method in ("POST", "PATCH"):
response = requests.request(
method=self.method,
url=f"{self.base}/{self.url}",
json=self.params,
headers=self.headers,
timeout=20,
)
else:
response = requests.request(
method=self.method,
url=f"{self.base}/{self.url}",
params=self.params,
headers=self.headers,
timeout=20,
)
self.response_data = response.json()
logger.info(
"API call: %s %s %s %s",
self.method,
self.url,
self.params,
self.response_data,
)
class CommuneCreation:
"""
This plugin handles setup tasks for French communes.
"""
_api_url = "https://recherche-entreprises.api.gouv.fr/search?q={siret}"
def get_organization_name_from_results(self, data, siret):
"""Return the organization name from the results of a SIRET search."""
for result in data["results"]:
nature = "nature_juridique"
commune = nature in result and result[nature] == "7210"
if commune:
return result["siege"]["libelle_commune"].title()
logger.warning("Not a commune: SIRET %s", siret)
return None
def dns_call(self, spec):
"""Call to add a DNS record"""
zone_name = self.zone_name(spec.inputs["name"])
records = [
{
"name": item["target"],
"type": item["type"].upper(),
"data": item["value"],
"ttl": 3600,
}
for item in spec.response_data
]
result = ApiCall()
result.method = "PATCH"
result.base = "https://api.scaleway.com"
result.url = f"/domain/v2beta1/dns-zones/{zone_name}/records"
result.params = {"changes": [{"add": {"records": records}}]}
result.headers = {"X-Auth-Token": settings.DNS_PROVISIONING_API_CREDENTIALS}
return result
def normalize_name(self, name: str) -> str:
"""Map the name to a standard form"""
name = re.sub("'", "-", name)
return slugify(name)
def zone_name(self, name: str) -> str:
"""Derive the zone name from the commune name"""
normalized = self.normalize_name(name)
return f"{normalized}.{settings.DNS_PROVISIONING_TARGET_ZONE}"
def complete_commune_creation(self, name: str) -> ApiCall:
"""Specify the tasks to be completed after a commune is created."""
inputs = {"name": self.normalize_name(name)}
create_zone = ApiCall()
create_zone.method = "POST"
create_zone.base = "https://api.scaleway.com"
create_zone.url = "/domain/v2beta1/dns-zones"
create_zone.params = {
"project_id": settings.DNS_PROVISIONING_RESOURCE_ID,
"domain": settings.DNS_PROVISIONING_TARGET_ZONE,
"subdomain": inputs["name"],
}
create_zone.headers = {
"X-Auth-Token": settings.DNS_PROVISIONING_API_CREDENTIALS
}
zone_name = self.zone_name(inputs["name"])
create_domain = ApiCall()
create_domain.method = "POST"
create_domain.base = settings.MAIL_PROVISIONING_API_URL
create_domain.url = "/domains/"
create_domain.params = {
"name": zone_name,
"delivery": "virtual",
"features": ["webmail", "mailbox"],
"context_name": zone_name,
}
create_domain.headers = {
"Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
}
spec_domain = ApiCall()
spec_domain.inputs = inputs
spec_domain.base = settings.MAIL_PROVISIONING_API_URL
spec_domain.url = f"/domains/{zone_name}/spec"
spec_domain.headers = {
"Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
}
return [create_zone, create_domain, spec_domain]
def complete_zone_creation(self, spec_call):
"""Specify the tasks to be performed to set up the zone."""
return self.dns_call(spec_call)
def run_after_create(self, organization):
"""After creating an organization, update the organization name."""
logger.info("In CommuneCreation")
if not organization.registration_id_list:
# No registration ID to convert...
return
# In the nominal case, there is only one registration ID because
# the organization has been created from it.
try:
# Retry logic as the API may be rate limited
s = requests.Session()
retries = Retry(total=5, backoff_factor=0.1, status_forcelist=[429])
s.mount("https://", HTTPAdapter(max_retries=retries))
siret = organization.registration_id_list[0]
response = s.get(self._api_url.format(siret=siret), timeout=10)
response.raise_for_status()
data = response.json()
name = self.get_organization_name_from_results(data, siret)
# Not a commune ?
if not name:
return
except requests.RequestException as exc:
logger.exception("%s: Unable to fetch organization name from SIRET", exc)
return
organization.name = name
organization.save(update_fields=["name", "updated_at"])
logger.info("Organization %s name updated to %s", organization, name)
zone_name = self.zone_name(name)
support = "support-regie@numerique.gouv.fr"
MailDomain.objects.get_or_create(name=zone_name, support_email=support)
# Compute and execute the rest of the process
tasks = self.complete_commune_creation(name)
for task in tasks:
task.execute()
last_task = self.complete_zone_creation(tasks[-1])
last_task.execute()
def complete_grant_access(self, sub, zone_name):
"""Specify the tasks to be completed after making a user admin"""
create_user = ApiCall()
create_user.method = "POST"
create_user.base = settings.MAIL_PROVISIONING_API_URL
create_user.url = "/users/"
create_user.params = {
"name": sub,
"password": "no",
"is_admin": False,
"perms": [],
}
create_user.headers = {
"Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
}
grant_access = ApiCall()
grant_access.method = "POST"
grant_access.base = settings.MAIL_PROVISIONING_API_URL
grant_access.url = "/allows/"
grant_access.params = {
"user": sub,
"domain": zone_name,
}
grant_access.headers = {
"Authorization": f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
}
return [create_user, grant_access]
def run_after_grant_access(self, organization_access):
"""After granting an organization access, check for needed domain access grant."""
orga = organization_access.organization
user = organization_access.user
zone_name = self.zone_name(orga.name)
try:
domain = MailDomain.objects.get(name=zone_name)
except MailDomain.DoesNotExist:
domain = None
if domain:
MailDomainAccess.objects.create(
domain=domain, user=user, role=MailDomainRoleChoices.OWNER
)
tasks = self.complete_grant_access(user.sub, zone_name)
for task in tasks:
task.execute()
@@ -1,231 +0,0 @@
"""Tests for the CommuneCreation plugin."""
from django.conf import settings
from django.test.utils import override_settings
import pytest
import responses
from plugins.la_suite.hooks_utils.communes import ApiCall, CommuneCreation
pytestmark = pytest.mark.django_db
def test_extract_name_from_org_data_when_commune():
"""Test the name is extracted correctly for a French commune."""
data = {
"results": [
{
"nom_complet": "COMMUNE DE VARZY",
"nom_raison_sociale": "COMMUNE DE VARZY",
"siege": {
"libelle_commune": "VARZY",
"liste_enseignes": ["MAIRIE"],
"siret": "21580304000017",
},
"nature_juridique": "7210",
"matching_etablissements": [
{
"siret": "21580304000017",
"libelle_commune": "VARZY",
"liste_enseignes": ["MAIRIE"],
}
],
}
]
}
plugin = CommuneCreation()
name = plugin.get_organization_name_from_results(data, "21580304000017")
assert name == "Varzy"
def test_api_call_execution():
"""Test that calling execute() faithfully executes the API call"""
task = ApiCall()
task.method = "POST"
task.base = "https://some_host"
task.url = "some_url"
task.params = {"some_key": "some_value"}
task.headers = {"Some-Header": "Some-Header-Value"}
with responses.RequestsMock() as rsps:
rsps.add(
rsps.POST,
url="https://some_host/some_url",
body='{"some_key": "some_value"}',
content_type="application/json",
headers={"Some-Header": "Some-Header-Value"},
)
task.execute()
@override_settings(DNS_PROVISIONING_TARGET_ZONE="collectivite.fr")
def test_tasks_on_commune_creation_include_zone_creation():
"""Test the first task in commune creation: creating the DNS sub-zone"""
plugin = CommuneCreation()
name = "Varzy"
tasks = plugin.complete_commune_creation(name)
assert tasks[0].base == "https://api.scaleway.com"
assert tasks[0].url == "/domain/v2beta1/dns-zones"
assert tasks[0].method == "POST"
assert tasks[0].params == {
"project_id": settings.DNS_PROVISIONING_RESOURCE_ID,
"domain": "collectivite.fr",
"subdomain": "varzy",
}
assert tasks[0].headers["X-Auth-Token"] == settings.DNS_PROVISIONING_API_CREDENTIALS
@override_settings(DNS_PROVISIONING_TARGET_ZONE="collectivite.fr")
def test_tasks_on_commune_creation_include_dimail_domain_creation():
"""Test the second task in commune creation: creating the domain in Dimail"""
plugin = CommuneCreation()
name = "Merlaut"
tasks = plugin.complete_commune_creation(name)
assert tasks[1].base == settings.MAIL_PROVISIONING_API_URL
assert tasks[1].url == "/domains/"
assert tasks[1].method == "POST"
assert tasks[1].params == {
"name": "merlaut.collectivite.fr",
"delivery": "virtual",
"features": ["webmail", "mailbox"],
"context_name": "merlaut.collectivite.fr",
}
assert (
tasks[1].headers["Authorization"]
== f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
)
@override_settings(DNS_PROVISIONING_TARGET_ZONE="collectivite.fr")
def test_tasks_on_commune_creation_include_fetching_spec():
"""Test the third task in commune creation: asking Dimail for the spec"""
plugin = CommuneCreation()
name = "Loc-Eguiner"
tasks = plugin.complete_commune_creation(name)
assert tasks[2].base == settings.MAIL_PROVISIONING_API_URL
assert tasks[2].url == "/domains/loc-eguiner.collectivite.fr/spec"
assert tasks[2].method == "GET"
assert (
tasks[2].headers["Authorization"]
== f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
)
@override_settings(DNS_PROVISIONING_TARGET_ZONE="collectivite.fr")
def test_tasks_on_commune_creation_include_dns_records():
"""Test the next several tasks in commune creation: creating records"""
plugin = CommuneCreation()
name = "Abidos"
spec_response = [
{"target": "", "type": "mx", "value": "mx.dev.ox.numerique.gouv.fr."},
{
"target": "dimail._domainkey",
"type": "txt",
"value": "v=DKIM1; h=sha256; k=rsa; p=MIICIjANB<truncated>AAQ==",
},
{"target": "imap", "type": "cname", "value": "imap.dev.ox.numerique.gouv.fr."},
{"target": "smtp", "type": "cname", "value": "smtp.dev.ox.numerique.gouv.fr."},
{
"target": "",
"type": "txt",
"value": "v=spf1 include:_spf.dev.ox.numerique.gouv.fr -all",
},
{
"target": "webmail",
"type": "cname",
"value": "webmail.dev.ox.numerique.gouv.fr.",
},
]
tasks = plugin.complete_commune_creation(name)
tasks[2].response_data = spec_response
expected = {
"changes": [
{
"add": {
"records": [
{
"name": item["target"],
"type": item["type"].upper(),
"data": item["value"],
"ttl": 3600,
}
for item in spec_response
]
}
}
]
}
zone_call = plugin.complete_zone_creation(tasks[2])
assert zone_call.params == expected
assert zone_call.url == "/domain/v2beta1/dns-zones/abidos.collectivite.fr/records"
assert (
zone_call.headers["X-Auth-Token"] == settings.DNS_PROVISIONING_API_CREDENTIALS
)
@override_settings(DNS_PROVISIONING_TARGET_ZONE="collectivite.fr")
def test_tasks_on_grant_access():
"""Test the final tasks after making user admin of an org"""
plugin = CommuneCreation()
tasks = plugin.complete_grant_access("some-sub", "mezos.collectivite.fr")
assert tasks[0].base == settings.MAIL_PROVISIONING_API_URL
assert tasks[0].url == "/users/"
assert tasks[0].method == "POST"
assert tasks[0].params == {
"name": "some-sub",
"password": "no",
"is_admin": False,
"perms": [],
}
assert (
tasks[0].headers["Authorization"]
== f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
)
assert tasks[1].base == settings.MAIL_PROVISIONING_API_URL
assert tasks[1].url == "/allows/"
assert tasks[1].method == "POST"
assert tasks[1].params == {
"user": "some-sub",
"domain": "mezos.collectivite.fr",
}
assert (
tasks[1].headers["Authorization"]
== f"Basic {settings.MAIL_PROVISIONING_API_CREDENTIALS}"
)
def test_normalize_name():
"""Test name normalization"""
plugin = CommuneCreation()
assert plugin.normalize_name("Asnières-sur-Saône") == "asnieres-sur-saone"
assert plugin.normalize_name("Bâgé-le-Châtel") == "bage-le-chatel"
assert plugin.normalize_name("Courçais") == "courcais"
assert plugin.normalize_name("Moÿ-de-l'Aisne") == "moy-de-l-aisne"
assert plugin.normalize_name("Salouël") == "salouel"
assert (
plugin.normalize_name("Bors (Canton de Tude-et-Lavalette)")
== "bors-canton-de-tude-et-lavalette"
)
@override_settings(DNS_PROVISIONING_TARGET_ZONE="collectivite.fr")
def test_zone_name():
"""Test transforming a commune name to a sub-zone of collectivite.fr"""
plugin = CommuneCreation()
assert plugin.zone_name("Bâgé-le-Châtel") == "bage-le-chatel.collectivite.fr"
@@ -61,8 +61,7 @@ def test_organization_plugins_run_after_create(
hook_settings, nature_juridique, is_commune, is_public_service
):
"""Test the run_after_create method of the organization plugins for nominal case."""
responses.add(
responses.GET,
responses.get(
"https://recherche-entreprises.api.gouv.fr/search?q=12345678901234",
json={
"results": [
@@ -106,8 +105,7 @@ def test_organization_plugins_run_after_create(
@responses.activate
def test_organization_plugins_run_after_create_api_fail(hook_settings):
"""Test the plugin when the API call fails."""
responses.add(
responses.GET,
responses.get(
"https://recherche-entreprises.api.gouv.fr/search?q=12345678901234",
json={"error": "Internal Server Error"},
status=500,
@@ -139,8 +137,7 @@ def test_organization_plugins_run_after_create_api_fail(hook_settings):
)
def test_organization_plugins_run_after_create_missing_data(hook_settings, results):
"""Test the plugin when the API call returns missing data."""
responses.add(
responses.GET,
responses.get(
"https://recherche-entreprises.api.gouv.fr/search?q=12345678901234",
json=results,
status=200,
@@ -168,8 +165,7 @@ def test_organization_plugins_run_after_create_no_list_enseignes(
hook_settings,
):
"""Test the run_after_create method of the organization plugins for nominal case."""
responses.add(
responses.GET,
responses.get(
"https://recherche-entreprises.api.gouv.fr/search?q=12345678901234",
json={
"results": [
@@ -26,15 +26,6 @@ def test_hooks_loaded():
]
assert organization_created_hook_names == [
"get_organization_name_and_metadata_from_siret_hook",
"commune_organization_created",
]
organization_access_granted_hook_names = [
callback.__name__
for callback in registry.get_callbacks("organization_access_granted")
]
assert organization_access_granted_hook_names == [
"commune_organization_access_granted"
]
# cleanup the hooks
+39 -38
View File
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "people"
version = "1.18.0"
version = "1.22.2"
authors = [{ "name" = "DINUM", "email" = "dev@mail.numerique.gouv.fr" }]
classifiers = [
"Development Status :: 5 - Production/Stable",
@@ -25,42 +25,43 @@ license = { file = "LICENSE" }
readme = "README.md"
requires-python = ">=3.11"
dependencies = [
"Brotli==1.1.0",
"Brotli==1.2.0",
"PyJWT==2.10.1",
"boto3==1.38.45",
"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.10",
"django-oauth-toolkit==3.0.1",
"django-lasuite==0.0.22",
"django-oauth-toolkit==3.2.0",
"django-parler==2.3",
"django-redis==5.4.0",
"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==5.2.3",
"djangorestframework==3.16.0",
"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",
"easy_thumbnails==2.10",
"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.1.0",
"jsonschema==4.24.0",
"mozilla-django-oidc==4.0.1",
"nested-multipart-parser==1.5.0",
"psycopg[binary]==3.2.9",
"redis<=6.0.0",
"requests==2.32.4",
"sentry-sdk[django]==2.32.0",
"whitenoise==6.9.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.49.0",
"whitenoise==6.11.0",
]
[project.urls]
@@ -71,22 +72,22 @@ dependencies = [
[project.optional-dependencies]
dev = [
"drf-spectacular-sidecar==2025.6.1",
"drf-spectacular-sidecar==2026.1.1",
"ipdb==0.13.13",
"ipython==9.3.0",
"jq==1.9.1",
"pyfakefs==5.9.1",
"pylint-django==2.6.1",
"pylint==3.3.7",
"pytest-cov==6.2.1",
"ipython==9.9.0",
"jq==1.10.0",
"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.7.0",
"responses==0.25.7",
"ruff==0.11.13",
"types-requests==2.32.4.20250611",
"freezegun==1.5.2",
"pytest-xdist==3.8.0",
"responses==0.25.8",
"ruff==0.14.11",
"types-requests==2.32.4.20260107",
"freezegun==1.5.5",
]
[tool.setuptools]
+7
View File
@@ -47,6 +47,13 @@ RUN yarn build
# ---- Front-end image ----
FROM nginxinc/nginx-unprivileged:alpine3.21 AS frontend-production
# Upgrade system packages to install security updates
USER root
RUN apk update && \
apk upgrade && \
rm -rf /var/cache/apk/*
USER -
# Un-privileged user running the application
ARG DOCKER_USER
USER ${DOCKER_USER}
+1 -1
View File
@@ -1 +1 @@
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
NEXT_PUBLIC_API_ORIGIN=http://localhost:8071
+1
View File
@@ -41,6 +41,7 @@ const nextConfig = {
return config;
},
turbopack: {},
};
module.exports = nextConfig;
+31 -31
View File
@@ -1,6 +1,6 @@
{
"name": "app-desk",
"version": "1.18.0",
"version": "1.22.2",
"private": true,
"scripts": {
"dev": "next dev",
@@ -16,53 +16,53 @@
},
"dependencies": {
"@gouvfr-lasuite/integration": "1.0.3",
"@gouvfr-lasuite/ui-kit": "^0.5.0",
"@gouvfr-lasuite/ui-kit": "^0.17.0",
"@hookform/resolvers": "4.0.0",
"@openfun/cunningham-react": "3.1.0",
"@tanstack/react-query": "5.76.1",
"i18next": "25.1.3",
"i18next-browser-languagedetector": "8.1.0",
"lodash": "4.17.21",
"luxon": "3.6.1",
"next": "15.3.2",
"@openfun/cunningham-react": "3.2.3",
"@tanstack/react-query": "5.90.5",
"i18next": "25.6.0",
"i18next-browser-languagedetector": "8.2.0",
"lodash": "4.17.23",
"luxon": "3.7.2",
"next": "15.4.10",
"react": "*",
"react-dom": "*",
"react-hook-form": "7.56.3",
"react-i18next": "15.5.1",
"react-select": "5.10.1",
"sass": "1.89.0",
"styled-components": "6.1.18",
"zod": "3.24.4",
"zustand": "5.0.4"
"react-hook-form": "7.65.0",
"react-i18next": "16.2.0",
"react-select": "5.10.2",
"sass": "1.93.2",
"styled-components": "6.1.19",
"zod": "4.1.12",
"zustand": "5.0.8"
},
"devDependencies": {
"@hookform/devtools": "4.4.0",
"@svgr/webpack": "8.1.0",
"@tanstack/react-query-devtools": "5.76.1",
"@testing-library/dom": "10.4.0",
"@testing-library/jest-dom": "6.6.3",
"@tanstack/react-query-devtools": "5.90.2",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "6.9.1",
"@testing-library/react": "16.3.0",
"@testing-library/user-event": "14.6.1",
"@types/jest": "29.5.14",
"@types/lodash": "4.17.16",
"@types/luxon": "3.6.2",
"@types/jest": "30.0.0",
"@types/lodash": "4.17.20",
"@types/luxon": "3.7.1",
"@types/node": "*",
"@types/react": "19.1.4",
"@types/react": "19.2.2",
"@types/react-dom": "*",
"dotenv": "16.5.0",
"dotenv": "17.2.3",
"eslint-config-people": "*",
"fetch-mock": "9.11.0",
"jest": "29.7.0",
"jest-environment-jsdom": "29.7.0",
"jest": "30.2.0",
"jest-environment-jsdom": "30.2.0",
"node-fetch": "2.7.0",
"prettier": "3.5.3",
"stylelint": "16.19.1",
"stylelint-config-standard": "38.0.0",
"prettier": "3.6.2",
"stylelint": "16.25.0",
"stylelint-config-standard": "39.0.1",
"stylelint-prettier": "5.0.3",
"typescript": "*"
},
"resolutions": {
"react": "19.0.0",
"react-dom": "19.0.0"
"react": "19.2.0",
"react-dom": "19.2.0"
}
}
@@ -19,13 +19,17 @@ export const Input = ({ label, error, required, ...props }: InputProps) => {
>
{label} {required && '*'}
</label>
{error && (
<Text $size="xs" $theme="danger" $variation="600">
{error}
</Text>
)}
<input
id={label}
aria-required={required}
required={required}
style={{
padding: '12px',
margin: '6px 0',
borderRadius: '4px',
fontSize: '14px',
border: `1px solid ${error ? colorsTokens()['danger-500'] : colorsTokens()['greyscale-400']}`,
@@ -34,11 +38,6 @@ export const Input = ({ label, error, required, ...props }: InputProps) => {
}}
{...props}
/>
{error && (
<Text $size="xs" $color="error-500">
{error}
</Text>
)}
</Box>
);
};
@@ -11,3 +11,4 @@ export * from './Tag';
export * from './Text';
export * from './TextErrors';
export * from './separators';
export * from './tabs/CustomTabs';
@@ -27,7 +27,7 @@ export const CustomTabs = ({ tabs }: Props) => {
const id = tab.id ?? tab.label;
return (
<Tab key={id} aria-label={tab.ariaLabel} id={id}>
<Box $direction="row" $align="center" $gap="5px">
<Box $direction="row" $gap="5px">
{tab.iconName && (
<span className="material-icons" aria-hidden="true">
{tab.iconName}
@@ -1,63 +1,69 @@
.customTabsContainer {
:global {
.react-aria-TabList {
display: flex;
display: flex;
width: 100%;
margin-top: 30px;
&[data-orientation='horizontal'] {
.react-aria-Tab {
border-bottom: 2px solid var(--c--theme--colors--greyscale-500);
}
}
:global(.react-aria-Tabs) {
width: 100%;
display: flex;
flex: 1;
&[data-orientation='horizontal'] {
flex-direction: column;
}
}
:global(.react-aria-TabList) {
display: flex;
width: 100%;
gap: 25px;
}
:global(.react-aria-Tab) {
display: flex;
padding: 0 10px 10px 10px;
cursor: pointer;
outline: none;
position: relative;
color: var(--c--theme--colors--secondary-900);
transition: color 200ms;
--border-color: transparent;
forced-color-adjust: none;
&[data-hovered] {
background-color: #fff;
color: var(--text-color-hover);
}
.react-aria-Tab {
padding: 10px;
cursor: pointer;
outline: none;
position: relative;
color: var(--c--theme--colors--greyscale-700);
transition: color 200ms;
--border-color: transparent;
forced-color-adjust: none;
&[data-hovered],
&[data-focused] {
color: var(--c--theme--colors--greyscale-900);
}
&[data-selected] {
font-weight: 500;
color: var(--c--theme--colors--primary-text);
border-bottom: 2px solid var(--c--theme--colors--primary-text);
}
&[data-disabled] {
color: var(--text-color-disabled);
&[data-selected] {
border-bottom: 2px solid var(--c--theme--colors--primary-600) !important;
color: var(--c--theme--colors--primary-600);
}
&[data-disabled] {
color: var(--c--theme--colors--greyscale-500);
&[data-selected] {
--border-color: var(--c--theme--colors--greyscale-200);
}
}
&[data-focus-visible]::after {
content: '';
position: absolute;
inset: 4px;
border-radius: 4px;
border: 1px solid var(--c--theme--colors--primary-600);
--border-color: var(--text-color-disabled);
}
}
.react-aria-TabPanel {
margin-top: 4px;
padding: 10px;
&[data-focus-visible]:after {
content: '';
position: absolute;
inset: 4px;
border-radius: 4px;
outline: none;
border: 2px solid var(--focus-ring-color);
}
}
&[data-focus-visible] {
outline: 2px solid var(--c--theme--colors--primary-600);
}
:global(.react-aria-TabPanel) {
margin-top: 15px;
border-radius: 4px;
outline: none;
&[data-focus-visible] {
outline: 2px solid var(--focus-ring-color);
}
}
}
@@ -75,7 +75,7 @@ describe('useDeleteMailDomainAccess', () => {
expect(onSuccess).toHaveBeenCalledWith(
undefined,
{ slug: 'example-slug', accessId: '1-1-1-1-1' },
undefined,
undefined, // context
),
);
expect(fetchMock.lastUrl()).toContain(
@@ -44,19 +44,42 @@ export const useCreateMailDomainAccess = (
options?: UseCreateMailDomainAccessOptions,
) => {
const queryClient = useQueryClient();
const {
onSuccess: optionsOnSuccess,
onError: optionsOnError,
...restOptions
} = options || {};
return useMutation<Access, APIError, CreateMailDomainAccessProps>({
mutationFn: createMailDomainAccess,
...options,
...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, 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, context) => {
options?.onError?.(error, variables, context);
if (optionsOnError) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
optionsOnError as unknown as (
error: APIError,
variables: CreateMailDomainAccessProps,
context: unknown,
) => void
)(error, variables, context);
}
},
});
};
@@ -46,9 +46,14 @@ export const useDeleteMailDomainAccess = (
options?: UseDeleteMailDomainAccessOptions,
) => {
const queryClient = useQueryClient();
const {
onSuccess: optionsOnSuccess,
onError: optionsOnError,
...restOptions
} = options || {};
return useMutation<void, APIError, DeleteMailDomainAccessProps>({
mutationFn: deleteMailDomainAccess,
...options,
...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, 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, context) => {
if (options?.onError) {
options.onError(error, variables, context);
if (optionsOnError) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
optionsOnError as unknown as (
error: APIError,
variables: DeleteMailDomainAccessProps,
context: unknown,
) => void
)(error, variables, context);
}
},
});
@@ -51,9 +51,14 @@ export const useUpdateMailDomainAccess = (
options?: UseUpdateMailDomainAccessOptions,
) => {
const queryClient = useQueryClient();
const {
onSuccess: optionsOnSuccess,
onError: optionsOnError,
...restOptions
} = options || {};
return useMutation<Access, APIError, UpdateMailDomainAccessProps>({
mutationFn: updateMailDomainAccess,
...options,
...restOptions,
onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_MAIL_DOMAIN_ACCESSES],
@@ -61,13 +66,27 @@ export const useUpdateMailDomainAccess = (
void queryClient.invalidateQueries({
queryKey: [KEY_MAIL_DOMAIN],
});
if (options?.onSuccess) {
options.onSuccess(data, variables, 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, context) => {
if (options?.onError) {
options.onError(error, variables, context);
if (optionsOnError) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
optionsOnError as unknown as (
error: APIError,
variables: UpdateMailDomainAccessProps,
context: unknown,
) => void
)(error, variables, context);
}
},
});
@@ -0,0 +1,4 @@
export * from './useAliases';
export * from './useAliasesInfinite';
export * from './useCreateAlias';
export * from './useDeleteAlias';
@@ -0,0 +1,57 @@
import { UseQueryOptions, useQuery } from '@tanstack/react-query';
import { APIError, APIList, errorCauses, fetchAPI } from '@/api';
import { Alias } from '../types';
export type MailDomainAliasesParams = {
mailDomainSlug: string;
page: number;
ordering?: string;
};
type MailDomainAliasesResponse = APIList<Alias>;
export const getMailDomainAliases = async ({
mailDomainSlug,
page,
ordering,
}: MailDomainAliasesParams): Promise<MailDomainAliasesResponse> => {
let url = `mail-domains/${mailDomainSlug}/aliases/?page=${page}`;
if (ordering) {
url += '&ordering=' + ordering;
}
const response = await fetchAPI(url);
if (!response.ok) {
throw new APIError(
`Failed to get the aliases of mail domain ${mailDomainSlug}`,
await errorCauses(response),
);
}
return response.json() as Promise<MailDomainAliasesResponse>;
};
export const KEY_LIST_ALIAS = 'aliases';
export function useAliases(
param: MailDomainAliasesParams,
queryConfig?: UseQueryOptions<
MailDomainAliasesResponse,
APIError,
MailDomainAliasesResponse
>,
) {
return useQuery<
MailDomainAliasesResponse,
APIError,
MailDomainAliasesResponse
>({
queryKey: [KEY_LIST_ALIAS, param],
queryFn: () => getMailDomainAliases(param),
...queryConfig,
});
}
@@ -0,0 +1,32 @@
import { useInfiniteQuery } from '@tanstack/react-query';
import { KEY_LIST_ALIAS, getMailDomainAliases } from './useAliases';
export type MailDomainAliasesInfiniteParams = {
mailDomainSlug: string;
ordering?: string;
};
export function useAliasesInfinite(
param: MailDomainAliasesInfiniteParams,
queryConfig = {},
) {
return useInfiniteQuery({
initialPageParam: 1,
queryKey: [KEY_LIST_ALIAS, param],
queryFn: ({ pageParam }) =>
getMailDomainAliases({
mailDomainSlug: param.mailDomainSlug,
page: pageParam,
ordering: param.ordering,
}),
getNextPageParam(lastPage, allPages) {
// When there is no more page, return undefined
if (!lastPage.next) {
return undefined;
}
return allPages.length + 1;
},
...queryConfig,
});
}
@@ -0,0 +1,83 @@
import {
UseMutationOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_LIST_ALIAS } from './useAliases';
export interface CreateAliasParams {
local_part: string;
destination: string;
mailDomainSlug: string;
}
export const createAlias = async ({
mailDomainSlug,
...data
}: CreateAliasParams): Promise<void> => {
const response = await fetchAPI(`mail-domains/${mailDomainSlug}/aliases/`, {
method: 'POST',
body: JSON.stringify(data),
});
if (!response.ok) {
const errorData = await errorCauses(response);
throw new APIError('Failed to create the alias', {
status: errorData.status,
cause: errorData.cause as string[],
data: errorData.data,
});
}
};
type UseCreateAliasParams = { mailDomainSlug: string } & UseMutationOptions<
void,
APIError,
CreateAliasParams
>;
export const useCreateAlias = (options: UseCreateAliasParams) => {
const queryClient = useQueryClient();
const {
onSuccess: optionsOnSuccess,
onError: optionsOnError,
...restOptions
} = options;
return useMutation<void, APIError, CreateAliasParams>({
mutationFn: createAlias,
...restOptions,
onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({
queryKey: [
KEY_LIST_ALIAS,
{ mailDomainSlug: variables.mailDomainSlug },
],
});
if (optionsOnSuccess) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
optionsOnSuccess as unknown as (
data: void,
variables: CreateAliasParams,
context: unknown,
) => void
)(data, variables, context);
}
},
onError: (error, variables, context) => {
if (optionsOnError) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
optionsOnError as unknown as (
error: APIError,
variables: CreateAliasParams,
context: unknown,
) => void
)(error, variables, context);
}
},
});
};
@@ -0,0 +1,79 @@
import {
UseMutationOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_LIST_ALIAS } from './useAliases';
interface DeleteAliasParams {
mailDomainSlug: string;
localPart: string;
}
export const deleteAlias = async ({
mailDomainSlug,
localPart,
}: DeleteAliasParams): Promise<void> => {
const response = await fetchAPI(
`mail-domains/${mailDomainSlug}/aliases/delete/?local_part=${encodeURIComponent(localPart)}`,
{
method: 'DELETE',
},
);
if (!response.ok) {
throw new APIError(
'Failed to delete the alias',
await errorCauses(response),
);
}
};
type UseDeleteAliasOptions = UseMutationOptions<
void,
APIError,
DeleteAliasParams
>;
export const useDeleteAlias = (options?: UseDeleteAliasOptions) => {
const queryClient = useQueryClient();
const {
onSuccess: optionsOnSuccess,
onError: optionsOnError,
...restOptions
} = options || {};
return useMutation<void, APIError, DeleteAliasParams>({
mutationFn: deleteAlias,
...restOptions,
onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_ALIAS],
});
if (optionsOnSuccess) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
optionsOnSuccess as unknown as (
data: void,
variables: DeleteAliasParams,
context: unknown,
) => void
)(data, variables, context);
}
},
onError: (error, variables, context) => {
if (optionsOnError) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
optionsOnError as unknown as (
error: APIError,
variables: DeleteAliasParams,
context: unknown,
) => void
)(error, variables, context);
}
},
});
};
@@ -0,0 +1,75 @@
import {
UseMutationOptions,
useMutation,
useQueryClient,
} from '@tanstack/react-query';
import { APIError, errorCauses, fetchAPI } from '@/api';
import { KEY_LIST_ALIAS } from './useAliases';
interface DeleteAliasByIdParams {
mailDomainSlug: string;
aliasId: string;
}
export const deleteAliasById = async ({
mailDomainSlug,
aliasId,
}: DeleteAliasByIdParams): Promise<void> => {
// Use aliasId (pk) directly in URL as per API lookup_field = "pk"
const response = await fetchAPI(
`mail-domains/${mailDomainSlug}/aliases/${aliasId}/`,
{
method: 'DELETE',
},
);
if (!response.ok) {
throw new APIError(
'Failed to delete the alias',
await errorCauses(response),
);
}
};
type UseDeleteAliasByIdOptions = UseMutationOptions<
void,
APIError,
DeleteAliasByIdParams
>;
export const useDeleteAliasById = (options?: UseDeleteAliasByIdOptions) => {
const queryClient = useQueryClient();
return useMutation<void, APIError, DeleteAliasByIdParams>({
mutationFn: deleteAliasById,
...options,
onSuccess: (data, variables, context) => {
void queryClient.invalidateQueries({
queryKey: [KEY_LIST_ALIAS],
});
if (options?.onSuccess) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
options.onSuccess as unknown as (
data: void,
variables: DeleteAliasByIdParams,
context: unknown,
) => void
)(data, variables, context);
}
},
onError: (error, variables, context) => {
if (options?.onError) {
// eslint-disable-next-line @typescript-eslint/no-unsafe-call, @typescript-eslint/no-explicit-any
(
options.onError as unknown as (
error: APIError,
variables: DeleteAliasByIdParams,
context: unknown,
) => void
)(error, variables, context);
}
},
});
};
@@ -0,0 +1,135 @@
import { Button, Input, Tooltip } from '@openfun/cunningham-react';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useCunninghamTheme } from '@/cunningham';
import { ModalCreateAlias } from '@/features/mail-domains/aliases/components';
import { AliasesListView } from '@/features/mail-domains/aliases/components/panel';
import { MailDomain } from '../../domains/types';
export function AliasesView({ mailDomain }: { mailDomain: MailDomain }) {
const [searchValue, setSearchValue] = useState('');
const [isCreateAliasFormVisible, setIsCreateAliasFormVisible] =
useState(false);
const { t } = useTranslation();
const { colorsTokens } = useCunninghamTheme();
const colors = colorsTokens();
const canCreateAlias = mailDomain.status === 'enabled';
const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
setSearchValue(event.target.value);
};
const clearInput = () => {
setSearchValue('');
};
const openModal = () => {
setIsCreateAliasFormVisible(true);
};
return (
<>
<div aria-label="Aliases panel" className="container">
<h3 style={{ fontWeight: 700, fontSize: '18px', marginBottom: 'base' }}>
{t('Aliases')}
</h3>
<div
className="sm:block md:flex"
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '20px',
gap: '1em',
}}
>
<div
style={{ width: 'calc(100% - 245px)' }}
className="c__input__wrapper__mobile"
>
<Input
style={{ width: '100%' }}
label={t('Search for an alias')}
icon={<span className="material-icons">search</span>}
rightIcon={
searchValue && (
<span
className="material-icons"
onClick={clearInput}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
clearInput();
}
}}
role="button"
tabIndex={0}
style={{ cursor: 'pointer' }}
>
close
</span>
)
}
value={searchValue}
onChange={handleInputChange}
/>
</div>
<div
className="hidden md:flex"
style={{
background: colors['greyscale-200'],
height: '32px',
width: '1px',
}}
></div>
<div
className="block md:hidden"
style={{ marginBottom: '10px' }}
></div>
<div>
{mailDomain?.abilities.post ? (
<Button
data-testid="button-new-alias"
aria-label={t('Create an alias in {{name}} domain', {
name: mailDomain?.name,
})}
disabled={!canCreateAlias}
onClick={() => setIsCreateAliasFormVisible(true)}
>
{t('New alias')}
</Button>
) : (
<Tooltip content={t("You don't have the correct access right")}>
<div>
<Button
data-testid="button-new-alias"
onClick={openModal}
disabled={!isCreateAliasFormVisible}
>
{t('New alias')}
</Button>
</div>
</Tooltip>
)}
</div>
</div>
<AliasesListView mailDomain={mailDomain} querySearch={searchValue} />
{isCreateAliasFormVisible && mailDomain ? (
<ModalCreateAlias
mailDomain={mailDomain}
closeModal={() => setIsCreateAliasFormVisible(false)}
/>
) : null}
</div>
</>
);
}
@@ -0,0 +1,438 @@
import { standardSchemaResolver } from '@hookform/resolvers/standard-schema';
import {
Button,
Loader,
ModalSize,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import React, { useState } from 'react';
import { Controller, FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { z } from 'zod';
import { parseAPIError } from '@/api/parseAPIError';
import {
Box,
HorizontalSeparator,
Icon,
Input,
Text,
TextErrors,
} from '@/components';
import { CustomModal } from '@/components/modal/CustomModal';
import { MailDomain } from '../../domains/types';
import { useCreateAlias } from '../api';
const FORM_ID = 'form-create-alias';
export const ModalCreateAlias = ({
mailDomain,
closeModal,
}: {
mailDomain: MailDomain;
closeModal: () => void;
}) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const [errorCauses, setErrorCauses] = useState<string[]>([]);
const [step] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [destinations, setDestinations] = useState<string[]>([]);
const [newDestination, setNewDestination] = useState('');
const [destinationError, setDestinationError] = useState<string | null>(null);
type AliasFormData = {
local_part: string;
};
const createAliasValidationSchema: z.ZodType<AliasFormData> = z.object({
local_part: z
.string()
.regex(/^((?!@|\s)([a-zA-Z0-9.\-]))*$/, t('Invalid format'))
.min(1, t('You must have minimum 1 character')),
});
const methods = useForm<AliasFormData>({
resolver: standardSchemaResolver(createAliasValidationSchema),
defaultValues: {
local_part: '',
},
mode: 'onChange',
});
const addDestination = () => {
const trimmed = newDestination.trim();
if (!trimmed) {
setDestinationError(t('Please enter an email address'));
return;
}
// Validation email
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(trimmed)) {
setDestinationError(t('Please enter a valid email address'));
return;
}
// Vérifier si déjà présent
if (destinations.includes(trimmed)) {
setDestinationError(t('This email address is already in the list'));
return;
}
setDestinations([...destinations, trimmed]);
setNewDestination('');
setDestinationError(null);
};
const removeDestination = (index: number) => {
setDestinations(destinations.filter((_, i) => i !== index));
};
const handleDestinationKeyPress = (
event: React.KeyboardEvent<HTMLInputElement>,
) => {
if (event.key === 'Enter') {
event.preventDefault();
addDestination();
}
};
const { mutate: createAlias } = useCreateAlias({
mailDomainSlug: mailDomain.slug,
});
const onSubmitCallback = async (event: React.FormEvent) => {
event.preventDefault();
const isValid = await methods.trigger();
if (!isValid) {
return;
}
if (destinations.length === 0) {
toast(t('Please add at least one destination email'), VariantType.ERROR, {
duration: 4000,
});
return;
}
const data = methods.getValues();
setIsSubmitting(true);
setErrorCauses([]);
let successCount = 0;
let errorCount = 0;
const allErrors: string[] = [];
for (const destination of destinations) {
try {
await new Promise<void>((resolve, reject) => {
createAlias(
{
local_part: data.local_part,
destination: destination.trim(),
mailDomainSlug: mailDomain.slug,
},
{
onSuccess: () => {
successCount++;
resolve();
},
onError: (error) => {
errorCount++;
const causes =
parseAPIError({
error,
errorParams: [
[
['Local part ".*" already used by a mailbox.'],
t('This email prefix is already used by a mailbox.'),
undefined,
],
[
['Invalid format'],
t('Invalid format for the email prefix.'),
undefined,
],
],
serverErrorParams: [
t(
'The domain must be enabled to create aliases. Please check the domain status.',
),
undefined,
],
}) || [];
if (causes.length > 0) {
allErrors.push(...causes);
}
reject(error);
},
},
);
});
} catch {
// Erreur déjà gérée dans onError
}
}
setIsSubmitting(false);
// Afficher les résultats
if (errorCount > 0) {
setErrorCauses(allErrors);
}
if (successCount === destinations.length) {
toast(
t('All {{count}} alias(es) created successfully!', {
count: successCount,
}),
VariantType.SUCCESS,
{ duration: 4000 },
);
closeModal();
} else if (successCount > 0) {
toast(
t('{{success}} alias(es) created, {{errors}} failed', {
success: successCount,
errors: errorCount,
}),
VariantType.WARNING,
{ duration: 5000 },
);
} else {
toast(t('Failed to create aliases'), VariantType.ERROR, {
duration: 4000,
});
}
};
const steps = [
{
title: t('New alias'),
content: (
<FormProvider {...methods}>
{!!errorCauses.length && <TextErrors causes={errorCauses} />}
<form
id={FORM_ID}
onSubmit={(e) => {
void onSubmitCallback(e);
}}
>
<Box $padding={{ top: 'sm', horizontal: 'md' }} $gap="4px">
<Text $size="md" $weight="bold">
{t('Alias configuration')}
</Text>
<Text $theme="greyscale" $variation="600">
{t(
'An alias allows you to redirect emails to one or more addresses.',
)}
</Text>
</Box>
<Box
$padding="md"
style={{
position: 'relative',
alignItems: 'end',
gap: '20px',
flexDirection: 'row',
alignContent: 'flex-end',
}}
>
<Controller
name="local_part"
control={methods.control}
render={({ field }) => (
<Box $align="center">
<Input
{...field}
label={t('Name of the alias')}
required
placeholder={t('contact')}
/>
</Box>
)}
/>
<Box
style={{
display: 'flex',
position: 'absolute',
top: '58px',
left: '210px',
}}
>
<Text className="mb-8" $weight="500">
@{mailDomain.name}
</Text>
</Box>
</Box>
<HorizontalSeparator $withPadding={true} />
<Box $padding={{ horizontal: 'md' }}>
<Box $margin={{ top: 'base', bottom: 'base' }} $gap="12px">
<Text $size="sm" $weight="500">
{t('Destination email addresses')}
</Text>
<Box $gap="4px">
<Box $direction="row" $gap="8px" $align="end">
<Box style={{ flex: 1 }}>
<Input
value={newDestination}
onChange={(e) => {
setNewDestination(e.target.value);
setDestinationError(null);
}}
onKeyPress={handleDestinationKeyPress}
label={t('Add destination email')}
placeholder=""
/>
</Box>
<Button
type="button"
onClick={addDestination}
disabled={!newDestination.trim()}
>
{t('Add destination')}
</Button>
</Box>
{destinationError && (
<Text $theme="warning" $size="sm">
{destinationError}
</Text>
)}
</Box>
{/* Destinations Array */}
{destinations.length > 0 && (
<Box
$margin={{ top: 'md' }}
style={{
border:
'1px solid var(--c--contextuals--border--surface--primary)',
borderRadius: '4px',
overflow: 'hidden',
}}
>
<table
style={{ width: '100%', borderCollapse: 'collapse' }}
>
<thead>
<tr
style={{
paddingBottom: '12px',
borderBottom:
'1px solid var(--c--contextuals--border--surface--primary)',
}}
>
<th
style={{
textAlign: 'left',
fontWeight: 500,
fontSize: '14px',
}}
>
{t('Email address')}
</th>
<th
style={{
paddingBottom: '12px',
textAlign: 'right',
width: '80px',
}}
>
{t('Actions')}
</th>
</tr>
</thead>
<tbody>
{destinations.map((destination, index) => (
<tr
key={index}
style={{
paddingBottom: '12px',
borderBottom:
index < destinations.length - 1
? '1px solid var(--c--contextuals--border--surface--primary)'
: 'none',
}}
>
<td>
<Text $size="sm">{destination}</Text>
</td>
<td style={{ textAlign: 'right' }}>
<Button
type="button"
color="tertiary"
onClick={() => removeDestination(index)}
aria-label={t('Remove destination')}
icon={<Icon iconName="delete" />}
/>
</td>
</tr>
))}
</tbody>
</table>
</Box>
)}
</Box>
</Box>
</form>
</FormProvider>
),
leftAction: (
<Button color="secondary" onClick={closeModal}>
{t('Cancel')}
</Button>
),
rightAction: (
<Button
type="submit"
form={FORM_ID}
disabled={
!methods.formState.isValid ||
destinations.length === 0 ||
isSubmitting
}
>
{isSubmitting ? t('Creating...') : t('Create alias')}
</Button>
),
},
];
return (
<div id="modal-new-alias">
<CustomModal
isOpen
hideCloseButton
step={step}
totalSteps={steps.length}
leftActions={steps[step].leftAction}
rightActions={steps[step].rightAction}
size={ModalSize.MEDIUM}
title={steps[step].title}
onClose={closeModal}
closeOnEsc
closeOnClickOutside
>
{steps[step].content}
{isSubmitting && (
<Box $align="center" $padding="md">
<Loader />
<Text $theme="greyscale" $variation="600" $margin={{ top: 'sm' }}>
{t('Creating alias...')}
</Text>
</Box>
)}
</CustomModal>
</div>
);
};
@@ -0,0 +1,578 @@
import {
Button,
Loader,
ModalSize,
VariantType,
useToastProvider,
} from '@openfun/cunningham-react';
import React, { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { parseAPIError } from '@/api/parseAPIError';
import {
Box,
HorizontalSeparator,
Icon,
Input,
Text,
TextErrors,
} from '@/components';
import { Modal } from '@/components/Modal';
import { CustomModal } from '@/components/modal/CustomModal';
import { MailDomain } from '../../domains/types';
import { useCreateAlias } from '../api/useCreateAlias';
import { useDeleteAlias } from '../api/useDeleteAlias';
import { useDeleteAliasById } from '../api/useDeleteAliasById';
import { AliasGroup } from '../types';
const FORM_ID = 'form-edit-alias';
export const ModalEditAlias = ({
mailDomain,
aliasGroup,
closeModal,
}: {
mailDomain: MailDomain;
aliasGroup: AliasGroup;
closeModal: () => void;
}) => {
const { t } = useTranslation();
const { toast } = useToastProvider();
const [errorCauses, setErrorCauses] = useState<string[]>([]);
const [step] = useState(0);
const [isSubmitting, setIsSubmitting] = useState(false);
const [destinations, setDestinations] = useState<string[]>([]);
const [newDestination, setNewDestination] = useState('');
const [destinationError, setDestinationError] = useState<string | undefined>(
undefined,
);
const [confirmModal, setConfirmModal] = useState<{
isOpen: boolean;
title: string;
message: string;
onConfirm: () => void;
}>({
isOpen: false,
title: '',
message: '',
onConfirm: () => {},
});
useEffect(() => {
setDestinations([...aliasGroup.destinations]);
}, [aliasGroup]);
const addDestination = async () => {
const trimmed = newDestination.trim();
if (!trimmed) {
setDestinationError(t('Please enter an email address'));
return;
}
// Valid email format
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
if (!emailRegex.test(trimmed)) {
setDestinationError(t('Please enter a valid email address'));
return;
}
// Email already in list
if (destinations.includes(trimmed)) {
setDestinationError(t('This email address is already in the list'));
return;
}
// Check domain enabled
if (mailDomain.status !== 'enabled') {
setDestinationError(
t(
'The domain must be enabled to add destinations. Current status: {{status}}',
{
status: mailDomain.status,
},
),
);
return;
}
setIsSubmitting(true);
setDestinationError(undefined);
try {
await new Promise<void>((resolve, reject) => {
createAlias(
{
local_part: aliasGroup.local_part,
destination: trimmed,
mailDomainSlug: mailDomain.slug,
},
{
onSuccess: () => {
toast(t('Destination added successfully'), VariantType.SUCCESS, {
duration: 4000,
});
setDestinations([...destinations, trimmed]);
setNewDestination('');
resolve();
},
onError: (error) => {
const causes =
parseAPIError({
error,
errorParams: [
[
['Local part ".*" already used by a mailbox.'],
t('This email prefix is already used by a mailbox.'),
undefined,
],
[
['Invalid format'],
t('Invalid format for the email prefix.'),
undefined,
],
],
serverErrorParams: [
t(
'The domain must be enabled to add destinations. Please check the domain status.',
),
undefined,
],
}) || [];
if (causes.length > 0) {
setDestinationError(causes[0]);
} else {
setDestinationError(
t('Failed to add destination. Please try again.'),
);
}
reject(error);
},
},
);
});
} catch {
} finally {
setIsSubmitting(false);
}
};
const removeDestination = (destination: string) => {
setConfirmModal({
isOpen: true,
title: t('Remove destination'),
message: t(
'Are you sure you want to remove {{destination}} from this alias?',
{ destination },
),
onConfirm: () => {
setConfirmModal({ ...confirmModal, isOpen: false });
void handleRemoveDestination(destination);
},
});
};
const handleRemoveDestination = async (destination: string) => {
setIsSubmitting(true);
const aliasId = aliasGroup.destinationIds[destination];
if (!aliasId) {
toast(
t('Failed to find alias ID for this destination'),
VariantType.ERROR,
{
duration: 4000,
},
);
setIsSubmitting(false);
return;
}
try {
await new Promise<void>((resolve, reject) => {
deleteAliasById(
{
mailDomainSlug: mailDomain.slug,
aliasId,
},
{
onSuccess: () => {
toast(
t('Destination removed successfully'),
VariantType.SUCCESS,
{
duration: 4000,
},
);
setDestinations(destinations.filter((d) => d !== destination));
resolve();
closeModal();
},
onError: (error) => {
const causes =
parseAPIError({
error,
errorParams: [],
serverErrorParams: [
t(
'An error occurred while removing the destination. Please try again.',
),
undefined,
],
}) || [];
if (causes.length > 0) {
setDestinationError(causes[0]);
} else {
toast(t('Failed to remove destination'), VariantType.ERROR, {
duration: 4000,
});
}
reject(error);
},
},
);
});
} catch {
} finally {
setIsSubmitting(false);
}
};
const handleRemoveAllDestinations = () => {
setConfirmModal({
isOpen: true,
title: t('Delete this alias'),
message: t(
'Are you sure you want to remove all destinations from this alias? This action cannot be undone.',
),
onConfirm: () => {
setConfirmModal({ ...confirmModal, isOpen: false });
void removeAllDestinations();
},
});
};
const removeAllDestinations = async () => {
if (destinations.length === 0) {
return;
}
setIsSubmitting(true);
setErrorCauses([]);
try {
await new Promise<void>((resolve, reject) => {
deleteAlias(
{
mailDomainSlug: mailDomain.slug,
localPart: aliasGroup.local_part,
},
{
onSuccess: () => {
toast(t('Alias deleted successfully'), VariantType.SUCCESS, {
duration: 4000,
});
setDestinations([]);
resolve();
closeModal();
},
onError: (error) => {
const causes =
parseAPIError({
error,
errorParams: [],
serverErrorParams: [
t(
'An error occurred while deleting the alias. Please try again.',
),
undefined,
],
}) || [];
if (causes.length > 0) {
setErrorCauses(causes);
} else {
toast(t('Failed to delete alias'), VariantType.ERROR, {
duration: 4000,
});
}
reject(error);
},
},
);
});
} catch {
} finally {
setIsSubmitting(false);
}
};
const handleDestinationKeyPress = (
event: React.KeyboardEvent<HTMLInputElement>,
) => {
if (event.key === 'Enter') {
event.preventDefault();
void addDestination();
}
};
const { mutate: createAlias } = useCreateAlias({
mailDomainSlug: mailDomain.slug,
});
const { mutate: deleteAliasById } = useDeleteAliasById();
const { mutate: deleteAlias } = useDeleteAlias();
const steps = [
{
title: t('Manage alias'),
content: (
<>
{!!errorCauses.length && <TextErrors causes={errorCauses} />}
<form
id={FORM_ID}
onSubmit={(e) => {
e.preventDefault();
void addDestination();
}}
>
<Box $padding={{ top: 'sm', horizontal: 'md' }} $gap="4px">
<Text $size="md" $weight="bold">
{t('Alias configuration')}
</Text>
<Text $theme="greyscale" $variation="600">
{t('Manage the destination email addresses for this alias.')}
</Text>
</Box>
<Box
$padding="md"
style={{
position: 'relative',
alignItems: 'end',
gap: '20px',
flexDirection: 'row',
alignContent: 'flex-end',
}}
>
<Box>
<Input
value={aliasGroup.local_part}
label={t('Name of the alias')}
disabled
readOnly
/>
</Box>
<Box
style={{
display: 'flex',
position: 'absolute',
top: '58px',
left: '210px',
}}
>
<Text className="mb-8" $weight="500">
@{mailDomain.name}
</Text>
</Box>
</Box>
<HorizontalSeparator $withPadding={true} />
<Box $padding={{ horizontal: 'md' }}>
<Box $margin={{ top: 'base', bottom: 'base' }} $gap="12px">
<Text $size="sm" $weight="500">
{t('Destination email addresses')}
</Text>
<Box $gap="4px">
<Box $direction="row" $gap="8px" $align="end">
<Box style={{ flex: 1 }}>
<Input
value={newDestination}
onChange={(e) => {
setNewDestination(e.target.value);
setDestinationError(undefined);
}}
onKeyPress={handleDestinationKeyPress}
error={destinationError}
label={t('Add destination email')}
placeholder={t('john.appleseed@example.fr')}
/>
</Box>
<Button
type="submit"
form={FORM_ID}
disabled={!newDestination.trim() || isSubmitting}
>
{isSubmitting ? t('Adding...') : t('Add')}
</Button>
</Box>
</Box>
{/* Tableau des destinations */}
{destinations.length > 0 && (
<Box
$margin={{ top: 'md' }}
$css={`
table tbody tr {
transition: background-color 0.2s ease;
cursor: pointer;
}
table tbody tr:hover {
background-color: rgba(0, 0, 0, 0.04);
}
`}
>
<table
style={{ width: '100%', borderCollapse: 'collapse' }}
>
<thead>
<tr
style={{
paddingBottom: '12px',
borderBottom:
'1px solid var(--c--contextuals--border--surface--primary)',
}}
>
<th
style={{
textAlign: 'left',
fontWeight: 500,
fontSize: '14px',
}}
>
{t('Email address')}
</th>
<th
style={{
paddingBottom: '12px',
textAlign: 'right',
width: '80px',
}}
>
{t('Actions')}
</th>
</tr>
</thead>
<tbody>
{destinations.map((destination, index) => (
<tr
key={index}
style={{
paddingBottom: '12px',
marginBottom: '12px',
}}
>
<td style={{ paddingLeft: '12px' }}>
<Text $size="sm">{destination}</Text>
</td>
<td style={{ textAlign: 'right' }}>
<Button
type="button"
color="tertiary"
onClick={() => removeDestination(destination)}
aria-label={t('Remove destination')}
icon={<Icon iconName="delete" />}
disabled={isSubmitting}
/>
</td>
</tr>
))}
</tbody>
</table>
</Box>
)}
</Box>
</Box>
</form>
</>
),
leftAction: (
<Button color="secondary" onClick={closeModal}>
{t('Close')}
</Button>
),
rightAction: (
<Box $direction="row" $gap="6px">
{destinations.length > 0 && (
<Button
type="button"
color="danger"
onClick={handleRemoveAllDestinations}
disabled={isSubmitting}
icon={
<Icon iconName="delete" $theme="greyscale" $variation="000" />
}
>
{t('Delete this alias')}
</Button>
)}
</Box>
),
},
];
return (
<div id="modal-edit-alias">
<CustomModal
isOpen
hideCloseButton
step={step}
totalSteps={steps.length}
leftActions={steps[step].leftAction}
rightActions={steps[step].rightAction}
size={ModalSize.MEDIUM}
title={steps[step].title}
onClose={closeModal}
closeOnEsc
closeOnClickOutside
>
{steps[step].content}
{isSubmitting && (
<Box $align="center" $padding="md">
<Loader />
<Text $theme="greyscale" $variation="600" $margin={{ top: 'sm' }}>
{t('Updating alias...')}
</Text>
</Box>
)}
</CustomModal>
{/* Confirmation Modal */}
<Modal
isOpen={confirmModal.isOpen}
closeOnClickOutside
hideCloseButton
leftActions={
<Button
color="secondary"
fullWidth
onClick={() => setConfirmModal({ ...confirmModal, isOpen: false })}
>
{t('Cancel')}
</Button>
}
rightActions={
<Button
color="danger"
fullWidth
onClick={confirmModal.onConfirm}
disabled={isSubmitting}
>
{t('Confirm')}
</Button>
}
size={ModalSize.MEDIUM}
title={confirmModal.title}
onClose={() => setConfirmModal({ ...confirmModal, isOpen: false })}
>
<Box $padding="md">
<Text>{confirmModal.message}</Text>
</Box>
</Modal>
</div>
);
};
@@ -0,0 +1,3 @@
export * from './AliasesView';
export * from './ModalCreateAlias';
export * from './ModalEditAlias';
@@ -0,0 +1,222 @@
import { Button, DataGrid, SortModel } from '@openfun/cunningham-react';
import type { InfiniteData } from '@tanstack/react-query';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Box, Text, TextErrors } from '@/components';
import { useAuthStore } from '@/core/auth';
import { Alias, AliasGroup } from '@/features/mail-domains/aliases/types';
import { MailDomain } from '@/features/mail-domains/domains';
import { useAliasesInfinite } from '../../api/useAliasesInfinite';
import { ModalEditAlias } from '../ModalEditAlias';
type MailDomainAliasesResponse = {
count: number;
next: string | null;
previous: string | null;
results: Alias[];
};
interface AliasesListViewProps {
mailDomain: MailDomain;
querySearch: string;
}
type SortModelItem = {
field: string;
sort: 'asc' | 'desc' | null;
};
function formatSortModel(sortModel: SortModelItem) {
return sortModel.sort === 'desc' ? `-${sortModel.field}` : sortModel.field;
}
export function AliasesListView({
mailDomain,
querySearch,
}: AliasesListViewProps) {
const { t } = useTranslation();
const { userData } = useAuthStore();
const [editingAliasGroup, setEditingAliasGroup] = useState<AliasGroup | null>(
null,
);
const [sortModel] = useState<SortModel>([]);
const ordering = sortModel.length ? formatSortModel(sortModel[0]) : undefined;
const {
data,
isLoading,
error,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
} = useAliasesInfinite({
mailDomainSlug: mailDomain.slug,
ordering,
}) as {
data: InfiniteData<MailDomainAliasesResponse, number> | undefined;
isLoading: boolean;
error: { cause?: string[] };
fetchNextPage: () => void;
hasNextPage: boolean | undefined;
isFetchingNextPage: boolean;
};
const aliasGroups: AliasGroup[] = useMemo(() => {
if (!mailDomain || !data?.pages?.length) {
return [];
}
const grouped = new Map<string, AliasGroup>();
data.pages.forEach((page) => {
page.results.forEach((alias: Alias) => {
const email = `${alias.local_part}@${mailDomain.name}`;
const existing = grouped.get(alias.local_part);
if (existing) {
if (!existing.destinations.includes(alias.destination)) {
existing.destinations.push(alias.destination);
existing.destinationIds[alias.destination] = alias.id;
existing.count_destinations = existing.destinations.length;
}
} else {
const destinationIds: Record<string, string> = {};
destinationIds[alias.destination] = alias.id;
grouped.set(alias.local_part, {
id: alias.local_part,
email,
local_part: alias.local_part,
destinations: [alias.destination],
destinationIds,
count_destinations: 1,
});
}
});
});
return Array.from(grouped.values());
}, [data, mailDomain]);
const filteredAliases = useMemo(() => {
if (!querySearch) {
return aliasGroups;
}
const lowerCaseSearch = querySearch.toLowerCase();
return aliasGroups.filter(
(alias) =>
alias.email.toLowerCase().includes(lowerCaseSearch) ||
alias.destinations.some((dest) =>
dest.toLowerCase().includes(lowerCaseSearch),
),
);
}, [querySearch, aliasGroups]);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
if (!hasNextPage) {
return;
}
const ref = loadMoreRef.current;
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ threshold: 1 },
);
if (ref) {
observer.observe(ref);
}
return () => {
if (ref) {
observer.unobserve(ref);
}
};
}, [hasNextPage, isFetchingNextPage, fetchNextPage]);
return (
<div>
{error && <TextErrors causes={error.cause ?? []} />}
{!filteredAliases.length && (
<Text $align="center" $size="small" $padding={{ top: 'base' }}>
{t('No alias was created with this mail domain.')}
</Text>
)}
{filteredAliases && filteredAliases.length ? (
<>
<DataGrid
aria-label="aliaslist"
rows={filteredAliases}
columns={[
{
field: 'email',
headerName: `${t('Alias')}${filteredAliases.length}`,
renderCell: ({ row }) => (
<Text $weight="400" $theme="greyscale">
{row.email}
</Text>
),
},
{
field: 'destinations',
headerName: t('Destinations'),
enableSorting: false,
renderCell: ({ row }) => (
<Text $weight="500" $theme="greyscale">
{row.count_destinations} destination
{row.count_destinations > 1 ? 's' : ''}
</Text>
),
},
{
id: 'actions',
renderCell: ({ row }) => {
// Check if user can edit
const isOwnerOrAdmin =
mailDomain.abilities?.patch || mailDomain.abilities?.put;
const isAliasDestination = row.destinations.some(
(dest) => dest === userData?.email,
);
const canEdit = isOwnerOrAdmin || isAliasDestination;
return (
<Box $direction="row" $gap="sm" $align="center">
{canEdit && (
<Button
color="tertiary"
onClick={() => setEditingAliasGroup(row)}
style={{
fontWeight: '500',
fontSize: '16px',
}}
>
{t('Manage')}
</Button>
)}
</Box>
);
},
},
]}
isLoading={isLoading}
/>
{isFetchingNextPage && <div>{t('Loading more...')}</div>}
</>
) : null}
<div ref={loadMoreRef} />
{editingAliasGroup && (
<ModalEditAlias
mailDomain={mailDomain}
aliasGroup={editingAliasGroup}
closeModal={() => setEditingAliasGroup(null)}
/>
)}
</div>
);
}
@@ -0,0 +1 @@
export * from './AliasesListView';

Some files were not shown because too many files have changed in this diff Show More