From 99433a6722d8b72e8751d0c0a367aa3d47994e08 Mon Sep 17 00:00:00 2001 From: Marie PUPO JEAMMET Date: Fri, 23 Jan 2026 16:39:48 +0100 Subject: [PATCH] =?UTF-8?q?=F0=9F=90=9B(aliases)=20alias=20destination=20c?= =?UTF-8?q?an=20be=20devnull@devnull?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 4 +++ .../mailbox_manager/migrations/0028_alias.py | 2 +- src/backend/mailbox_manager/models.py | 11 ++++++- .../api/aliases/test_api_aliases_create.py | 30 +++++++++++++++++++ .../tests/models/test_aliases.py | 19 ++++++++++++ .../tests/test_utils_dimail_client.py | 10 +++++-- 6 files changed, 72 insertions(+), 4 deletions(-) create mode 100644 src/backend/mailbox_manager/tests/models/test_aliases.py diff --git a/CHANGELOG.md b/CHANGELOG.md index b74f1f5..54861db 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,10 @@ and this project adheres to ## [Unreleased] +### Fixed + +- 🐛(aliases) authorize special domain devnull in alias destinations #1029 + ## [1.22.1] - 2026-01-21 - 🔒️(organization) the first user is not admin #776 diff --git a/src/backend/mailbox_manager/migrations/0028_alias.py b/src/backend/mailbox_manager/migrations/0028_alias.py index cf1ab8d..d941a49 100644 --- a/src/backend/mailbox_manager/migrations/0028_alias.py +++ b/src/backend/mailbox_manager/migrations/0028_alias.py @@ -19,7 +19,7 @@ class Migration(migrations.Migration): ('created_at', models.DateTimeField(auto_now_add=True, help_text='date and time at which a record was created', verbose_name='created at')), ('updated_at', models.DateTimeField(auto_now=True, help_text='date and time at which a record was last updated', verbose_name='updated at')), ('local_part', models.CharField(max_length=100)), - ('destination', models.EmailField(max_length=254, verbose_name='destination address')), + ('destination', models.CharField(max_length=254, validators=[django.core.validators.EmailValidator(allowlist=['localhost', 'devnull'])], verbose_name='destination address')), ('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='aliases', to='mailbox_manager.maildomain')), ], options={ diff --git a/src/backend/mailbox_manager/models.py b/src/backend/mailbox_manager/models.py index 29bfcde..0cbb344 100644 --- a/src/backend/mailbox_manager/models.py +++ b/src/backend/mailbox_manager/models.py @@ -9,6 +9,7 @@ from django.conf import settings from django.contrib.auth.base_user import AbstractBaseUser from django.contrib.sites.models import Site from django.core import exceptions, mail, validators +from django.core.validators import EmailValidator from django.db import models from django.db.models.functions import Lower from django.template.loader import render_to_string @@ -460,7 +461,15 @@ class Alias(BaseModel): """Model for aliases.""" local_part = models.CharField(max_length=100, blank=False) - destination = models.EmailField(_("destination address"), null=False, blank=False) + destination = models.CharField( + _("destination address"), + max_length=254, + null=False, + blank=False, + validators=[ + EmailValidator(allowlist=["localhost", "devnull"]), + ], + ) domain = models.ForeignKey( MailDomain, on_delete=models.CASCADE, diff --git a/src/backend/mailbox_manager/tests/api/aliases/test_api_aliases_create.py b/src/backend/mailbox_manager/tests/api/aliases/test_api_aliases_create.py index f7211a4..5e37a2a 100644 --- a/src/backend/mailbox_manager/tests/api/aliases/test_api_aliases_create.py +++ b/src/backend/mailbox_manager/tests/api/aliases/test_api_aliases_create.py @@ -182,3 +182,33 @@ def test_api_aliases_create__existing_mailbox_ok(dimail_token_ok): ) 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() diff --git a/src/backend/mailbox_manager/tests/models/test_aliases.py b/src/backend/mailbox_manager/tests/models/test_aliases.py new file mode 100644 index 0000000..6c87c37 --- /dev/null +++ b/src/backend/mailbox_manager/tests/models/test_aliases.py @@ -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", + ) diff --git a/src/backend/mailbox_manager/tests/test_utils_dimail_client.py b/src/backend/mailbox_manager/tests/test_utils_dimail_client.py index d5b54a8..4923a3b 100644 --- a/src/backend/mailbox_manager/tests/test_utils_dimail_client.py +++ b/src/backend/mailbox_manager/tests/test_utils_dimail_client.py @@ -199,6 +199,12 @@ def test_dimail_synchronization__synchronize_aliases(dimail_token_ok): # pylint "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/"), @@ -209,8 +215,8 @@ def test_dimail_synchronization__synchronize_aliases(dimail_token_ok): # pylint imported_aliases = dimail_client.import_aliases(alias.domain) - assert len(imported_aliases) == 3 - assert models.Alias.objects.count() == 4 + assert len(imported_aliases) == 4 + assert models.Alias.objects.count() == 5 @pytest.mark.parametrize(