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/doc/upgrade.rst b/doc/upgrade.rst index 6ccf12a03..f13462c34 100644 --- a/doc/upgrade.rst +++ b/doc/upgrade.rst @@ -130,6 +130,27 @@ Rebuild Virtual Environment Specific instructions ********************* +Version 2.6.0 +============= + +New built-in auto-configuration service. + +Required changes to :file:`settings.py` +--------------------------------------- + +- Add the following line to ``MODOBOA_APPS`` variable: + + .. sourcecode:: python + + 'modoboa.autoconfig' + +- Add the following hostnames to ``ALLOWED_HOSTS`` variable: + + .. sourcecode:: python + + 'autoconfig.', + 'autodiscover.', + Version 2.5.0 ============= 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..2477d2d38 --- /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 }} + plain + {{ emailaddress }} + + + + {{ connection_settings.smtp.HOSTNAME }} + {{ connection_settings.smtp.PORT }} + {{ connection_settings.smtp.SOCKET_TYPE }} + plain + {{ 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/tests.py b/modoboa/autoconfig/tests.py new file mode 100644 index 000000000..c43a43eef --- /dev/null +++ b/modoboa/autoconfig/tests.py @@ -0,0 +1,38 @@ +from django.test import TestCase +from django.urls import reverse + + +class ViewsTestCase(TestCase): + + databases = "__all__" + + 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}?emailaddress=test@test.com") + self.assertEqual(resp.status_code, 200) diff --git a/modoboa/autoconfig/urls.py b/modoboa/autoconfig/urls.py new file mode 100644 index 000000000..b53299dc1 --- /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( + "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..932e8c1b5 --- /dev/null +++ b/modoboa/autoconfig/views.py @@ -0,0 +1,109 @@ +import plistlib +import uuid + +from django.conf import settings +from django.http import Http404, HttpResponse +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(generic.View): + + http_method_names = ["get"] + + def get(self, request, *args, **kwargs): + emailaddress = self.request.GET.get("emailaddress") + if not emailaddress: + raise Http404 + local_part, domain = split_address(emailaddress) + parts = domain.split(".") + parts.reverse() + reverse_domain = ".".join(parts) + imap_settings = settings.EMAIL_CLIENT_CONNECTION_SETTINGS["imap"] + smtp_settings = settings.EMAIL_CLIENT_CONNECTION_SETTINGS["smtp"] + profile = { + "PayloadType": "Configuration", + "PayloadVersion": 1, + "PayloadIdentifier": f"{reverse_domain}.mailprofile", + "PayloadUUID": str(uuid.uuid4()), + "PayloadDisplayName": f"{domain} Mail Configuration", + "PayloadOrganization": domain, + "PayloadContent": [ + { + "PayloadType": "com.apple.mail.managed", + "PayloadVersion": 1, + "PayloadIdentifier": f"{reverse_domain}.mailprofile.mail", + "PayloadUUID": str(uuid.uuid4()), + "PayloadDisplayName": "Mail", + "EmailAccountDescription": f"{domain} Mail", + "EmailAccountType": "EmailTypeIMAP", + "EmailAddress": emailaddress, + # incoming + "IncomingMailServerHostName": imap_settings["HOSTNAME"], + "IncomingMailServerPortNumber": imap_settings["PORT"], + "IncomingMailServerUseSSL": True, + "IncomingMailServerUsername": emailaddress, + # outgoing + "OutgoingMailServerHostName": smtp_settings["HOSTNAME"], + "OutgoingMailServerPortNumber": smtp_settings["PORT"], + "OutgoingMailServerUseSSL": True, + "OutgoingMailServerUsername": emailaddress, + "OutgoingPasswordSameAsIncomingPassword": True, + } + ], + } + plist_bytes = plistlib.dumps(profile, fmt=plistlib.FMT_XML) + resp = HttpResponse( + plist_bytes, content_type="application/x-apple-aspen-config" + ) + resp["Content-Disposition"] = 'attachment; filename="mail.mobileconfig"' + return resp 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",