From b5ddc00d35441bc32a1c5c863fd8603e17791080 Mon Sep 17 00:00:00 2001 From: Antoine Nguyen Date: Tue, 16 Sep 2025 17:00:48 +0200 Subject: [PATCH] Autoconfiguration service --- LICENSE | 2 +- modoboa/autoconfig/__init__.py | 0 modoboa/autoconfig/apps.py | 6 ++ .../templates/autoconfig/autoconfig.xml | 24 ++++++ .../templates/autoconfig/autodiscover.xml | 27 ++++++ .../templates/autoconfig/mobileconfig.xml | 37 ++++++++ modoboa/autoconfig/tests.py | 38 ++++++++ modoboa/autoconfig/urls.py | 21 +++++ modoboa/autoconfig/views.py | 86 +++++++++++++++++++ .../core/commands/templates/settings.py.tpl | 14 +++ modoboa/urls.py | 1 + test_project/test_project/settings.py | 11 +++ 12 files changed, 266 insertions(+), 1 deletion(-) create mode 100644 modoboa/autoconfig/__init__.py create mode 100644 modoboa/autoconfig/apps.py create mode 100644 modoboa/autoconfig/templates/autoconfig/autoconfig.xml create mode 100644 modoboa/autoconfig/templates/autoconfig/autodiscover.xml create mode 100644 modoboa/autoconfig/templates/autoconfig/mobileconfig.xml create mode 100644 modoboa/autoconfig/tests.py create mode 100644 modoboa/autoconfig/urls.py create mode 100644 modoboa/autoconfig/views.py diff --git a/LICENSE b/LICENSE index a00625545..de4bc284b 100644 --- a/LICENSE +++ b/LICENSE @@ -1,6 +1,6 @@ ISC License -Copyright (c) 2009-2023, Antoine Nguyen +Copyright (c) 2009-2025, Antoine Nguyen Permission to use, copy, modify, and/or distribute this software for any purpose with or without fee is hereby granted, provided that the above diff --git a/modoboa/autoconfig/__init__.py b/modoboa/autoconfig/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/modoboa/autoconfig/apps.py b/modoboa/autoconfig/apps.py new file mode 100644 index 000000000..c4d33123c --- /dev/null +++ b/modoboa/autoconfig/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class AutoconfigConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "modoboa.autoconfig" diff --git a/modoboa/autoconfig/templates/autoconfig/autoconfig.xml b/modoboa/autoconfig/templates/autoconfig/autoconfig.xml new file mode 100644 index 000000000..e8af4ac61 --- /dev/null +++ b/modoboa/autoconfig/templates/autoconfig/autoconfig.xml @@ -0,0 +1,24 @@ + + + + {{ domain }} + Modoboa + Mail + + + {{ connection_settings.imap.HOSTNAME }} + {{ connection_settings.imap.PORT }} + {{ connection_settings.imap.SOCKET_TYPE }} + password-cleartext + {{ emailaddress }} + + + + {{ connection_settings.smtp.HOSTNAME }} + {{ connection_settings.smtp.PORT }} + {{ connection_settings.smtp.SOCKET_TYPE }} + password-cleartext + {{ emailaddress }} + + + diff --git a/modoboa/autoconfig/templates/autoconfig/autodiscover.xml b/modoboa/autoconfig/templates/autoconfig/autodiscover.xml new file mode 100644 index 000000000..feb6cd61e --- /dev/null +++ b/modoboa/autoconfig/templates/autoconfig/autodiscover.xml @@ -0,0 +1,27 @@ + + + + + email + settings + + + IMAP + {{ connection_settings.imap.HOSTNAME }} + {{ connection_settings.imap.PORT }} + true + {{ emailaddress }} + + + + SMTP + {{ connection_settings.smtp.HOSTNAME }} + {{ connection_settings.smtp.PORT }} + false + true + {{ emailaddress }} + + + + + diff --git a/modoboa/autoconfig/templates/autoconfig/mobileconfig.xml b/modoboa/autoconfig/templates/autoconfig/mobileconfig.xml new file mode 100644 index 000000000..832efe86d --- /dev/null +++ b/modoboa/autoconfig/templates/autoconfig/mobileconfig.xml @@ -0,0 +1,37 @@ + + + + + + + com.apple.mail.managed + 1 + {{ reverse_domain }}.email + {{ content_uuid }} + Mail + + Modoboa + EmailTypeIMAP + {{ emailaddress }} + {{ connection_settings.imap.HOSTNAME }} + {{ connection_settings.imap.PORT }} + true + {{ emailaddress }} + + {{ connection_settings.smtp.HOSTNAME }} + {{ connection_settings.smtp.PORT }} + true + {{ emailaddress }} + true + + + + Modoboa + Mail Configuration + {{ reverse_domain }}.mailconfig + {{ global_uuid }} + 1 + Configuration + + diff --git a/modoboa/autoconfig/tests.py b/modoboa/autoconfig/tests.py new file mode 100644 index 000000000..bec5b0c69 --- /dev/null +++ b/modoboa/autoconfig/tests.py @@ -0,0 +1,38 @@ +from django.test import TestCase +from django.urls import reverse + + +class ViewsTestCase(TestCase): + + def test_autoconfig(self): + url = reverse("autoconfig:autoconfig") + + resp = self.client.get(url) + self.assertEqual(resp.status_code, 404) + + resp = self.client.get(f"{url}?emailaddress=test@test.com") + self.assertEqual(resp.status_code, 200) + + self.assertIn(b'', resp.content) + + def test_autodiscover(self): + url = reverse("autoconfig:autodiscover") + + resp = self.client.post(url) + self.assertEqual(resp.status_code, 404) + + resp = self.client.post(url, {"EmailAddress": "test@test.com"}) + self.assertEqual(resp.status_code, 200) + + self.assertIn(b"test@test.com", resp.content) + + def test_mobileconfig(self): + url = reverse("autoconfig:mobileconfig") + + resp = self.client.get(url) + self.assertEqual(resp.status_code, 404) + + resp = self.client.get(f"{url}?email=test@test.com") + self.assertEqual(resp.status_code, 200) + + self.assertIn(b"test@test.com", resp.content) diff --git a/modoboa/autoconfig/urls.py b/modoboa/autoconfig/urls.py new file mode 100644 index 000000000..227fbf2fd --- /dev/null +++ b/modoboa/autoconfig/urls.py @@ -0,0 +1,21 @@ +"""Autoconfig urls.""" + +from django.urls import path + +from modoboa.autoconfig import views + +app_name = "autoconfig" + +urlpatterns = [ + path( + ".well-known/autoconfig/mail/config-v1.1.xml", + views.AutoConfigView.as_view(), + name="autoconfig", + ), + path( + "autodiscover/autodiscover.xml", + views.AutoDiscoverView.as_view(), + name="autodiscover", + ), + path("mobileconfig", views.MobileConfigView.as_view(), name="mobileconfig"), +] diff --git a/modoboa/autoconfig/views.py b/modoboa/autoconfig/views.py new file mode 100644 index 000000000..112a94d57 --- /dev/null +++ b/modoboa/autoconfig/views.py @@ -0,0 +1,86 @@ +import uuid + +from django.conf import settings +from django.http import Http404, HttpResponse +from django.template.loader import render_to_string +from django.utils.decorators import method_decorator +from django.views import generic +from django.views.decorators.csrf import csrf_exempt + +from modoboa.lib.email_utils import split_address + + +class ConfigBaseMixin: + + content_type = "application/xml" + + def get_common_context(self, emailaddress: str) -> dict: + local_part, domain = split_address(emailaddress) + return { + "emailaddress": emailaddress, + "domain": domain, + "connection_settings": settings.EMAIL_CLIENT_CONNECTION_SETTINGS, + } + + +class AutoConfigView(ConfigBaseMixin, generic.TemplateView): + + http_method_names = ["get"] + template_name = "autoconfig/autoconfig.xml" + + def get_context_data(self, **kwargs): + emailaddress = self.request.GET.get("emailaddress") + if not emailaddress: + raise Http404 + context = super().get_context_data(**kwargs) + context.update(self.get_common_context(emailaddress)) + return context + + +@method_decorator(csrf_exempt, name="dispatch") +class AutoDiscoverView(ConfigBaseMixin, generic.TemplateView): + + http_method_names = ["post"] + template_name = "autoconfig/autodiscover.xml" + + def get_context_data(self, **kwargs): + emailaddress = self.request.POST.get("EmailAddress") + if not emailaddress: + raise Http404 + context = super().get_context_data(**kwargs) + context.update(self.get_common_context(emailaddress)) + return context + + def post(self, request, *args, **kwargs): + context = self.get_context_data(**kwargs) + return self.render_to_response(context) + + +class MobileConfigView(ConfigBaseMixin, generic.TemplateView): + + content_type = "application/x-apple-aspen-config" + http_method_names = ["get"] + template_name = "autoconfig/mobileconfig.xml" + + def get_context_data(self, **kwargs): + emailaddress = self.request.GET.get("email") + if not emailaddress: + raise Http404 + context = super().get_context_data(**kwargs) + context.update(self.get_common_context(emailaddress)) + parts = context["domain"].split(".") + parts.reverse() + context.update( + { + "reverse_domain": ".".join(parts), + "content_uuid": uuid.uuid4(), + "global_uuid": uuid.uuid4(), + } + ) + return context + + def render_to_response(self, context, **responsse_kwargs): + content = render_to_string(self.template_name, context) + response = HttpResponse(content, content_type=self.content_type) + response["Content-Disposition"] = 'attachment; filename="mail.mobileconfig"' + return response diff --git a/modoboa/core/commands/templates/settings.py.tpl b/modoboa/core/commands/templates/settings.py.tpl index 84f5cf847..8eb4cf226 100644 --- a/modoboa/core/commands/templates/settings.py.tpl +++ b/modoboa/core/commands/templates/settings.py.tpl @@ -38,6 +38,19 @@ SITE_ID = 1 # The email address that error messages come from, such as those sent to ADMINS #SERVER_EMAIL = 'webmaster@example.net' +EMAIL_CLIENT_CONNECTION_SETTINGS = { + 'imap': { + 'HOSTNAME': '{{ allowed_host }}', + 'SOCKET_TYPE': 'SSL', + 'PORT': 993, + }, + 'smtp': { + 'HOSTNAME': '{{ allowed_host }}', + 'SOCKET_TYPE': 'STARTTLS', + 'PORT': 587 + } +} + # Security settings X_FRAME_OPTIONS = "SAMEORIGIN" @@ -75,6 +88,7 @@ MODOBOA_APPS = ( 'modoboa.core', 'modoboa.lib', 'modoboa.admin', + 'modoboa.autoconfig', 'modoboa.transport', 'modoboa.relaydomains', 'modoboa.limits', diff --git a/modoboa/urls.py b/modoboa/urls.py index 9b88d13f3..91c5b739b 100644 --- a/modoboa/urls.py +++ b/modoboa/urls.py @@ -13,6 +13,7 @@ from modoboa.core.extensions import exts_pool urlpatterns = [ path("", include("modoboa.core.urls")), + path("", include("modoboa.autoconfig.urls")), path( "accounts/password_reset/", core_views.PasswordResetView.as_view(), diff --git a/test_project/test_project/settings.py b/test_project/test_project/settings.py index 24d83cd5b..3a9c79627 100644 --- a/test_project/test_project/settings.py +++ b/test_project/test_project/settings.py @@ -50,6 +50,15 @@ DEFAULT_AUTO_FIELD = "django.db.models.AutoField" # SERVER_EMAIL = 'webmaster@example.net' EMAIL_BACKEND = "django.core.mail.backends.console.EmailBackend" +EMAIL_CLIENT_CONNECTION_SETTINGS = { + "imap": { + "HOSTNAME": "localhost", + "SOCKET_TYPE": "SSL", + "PORT": 993, + }, + "smtp": {"HOSTNAME": "localhost", "SOCKET_TYPE": "STARTTLS", "PORT": 587}, +} + # Security settings X_FRAME_OPTIONS = "SAMEORIGIN" @@ -87,6 +96,7 @@ MODOBOA_APPS = ( "modoboa.core", "modoboa.lib", "modoboa.admin", + "modoboa.autoconfig", "modoboa.transport", "modoboa.relaydomains", "modoboa.limits", @@ -397,6 +407,7 @@ TEST_RUNNER = "modoboa.lib.test_runners.UnManagedModelTestRunner" # We force sqlite backend for tests because the generated database is # not the same as the one provided by amavis... + DATABASES["amavis"] = { # noqa "ENGINE": "django.db.backends.sqlite3", "NAME": "amavis.db",