Autoconfiguration service

This commit is contained in:
Antoine Nguyen
2025-09-16 17:00:48 +02:00
parent c7e38e4496
commit b5ddc00d35
12 changed files with 266 additions and 1 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
ISC License
Copyright (c) 2009-2023, Antoine Nguyen <tonio@ngyn.org>
Copyright (c) 2009-2025, Antoine Nguyen <tonio@ngyn.org>
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted, provided that the above
View File
+6
View File
@@ -0,0 +1,6 @@
from django.apps import AppConfig
class AutoconfigConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "modoboa.autoconfig"
@@ -0,0 +1,24 @@
<?xml version="1.0" encoding="UTF-8"?>
<clientConfig version="1.1">
<emailProvider id="{{ domain }}">
<domain>{{ domain }}</domain>
<displayName>Modoboa</displayName>
<displayShortName>Mail</displayShortName>
<incomingServer type="imap">
<hostname>{{ connection_settings.imap.HOSTNAME }}</hostname>
<port>{{ connection_settings.imap.PORT }}</port>
<socketType>{{ connection_settings.imap.SOCKET_TYPE }}</socketType>
<authentication>password-cleartext</authentication>
<username>{{ emailaddress }}</username>
</incomingServer>
<outgoingServer type="smtp">
<hostname>{{ connection_settings.smtp.HOSTNAME }}</hostname>
<port>{{ connection_settings.smtp.PORT }}</port>
<socketType>{{ connection_settings.smtp.SOCKET_TYPE }}</socketType>
<authentication>password-cleartext</authentication>
<username>{{ emailaddress }}</username>
</outgoingServer>
</emailProvider>
</clientConfig>
@@ -0,0 +1,27 @@
<?xml version="1.0" encoding="UTF-8"?>
<Autodiscover xmlns="http://schemas.microsoft.com/exchange/autodiscover/responseschema/2006">
<Response xmlns="http://schemas.microsoft.com/exchange/autodiscover/outlook/responseschema/2006a">
<Account>
<AccountType>email</AccountType>
<Action>settings</Action>
<Protocol>
<Type>IMAP</Type>
<Server>{{ connection_settings.imap.HOSTNAME }}</Server>
<Port>{{ connection_settings.imap.PORT }}</Port>
<SSL>true</SSL>
<LoginName>{{ emailaddress }}</LoginName>
</Protocol>
<Protocol>
<Type>SMTP</Type>
<Server>{{ connection_settings.smtp.HOSTNAME }}</Server>
<Port>{{ connection_settings.smtp.PORT }}</Port>
<SSL>false</SSL>
<TLS>true</TLS>
<LoginName>{{ emailaddress }}</LoginName>
</Protocol>
</Account>
</Response>
</Autodiscover>
@@ -0,0 +1,37 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<payloadContent>
<dict>
<PayloadType>com.apple.mail.managed</PayloadType>
<PayloadVersion>1</PayloadVersion>
<PayloadIdentifier>{{ reverse_domain }}.email</PayloadIdentifier>
<PayloadUUID>{{ content_uuid }}</PayloadUUID>
<PayloadDisplayName>Mail</PayloadDisplayName>
<EmailAccountDescription>Modoboa</EmailAccountDescription>
<EmailAccountType>EmailTypeIMAP</EmailAccountType>
<EmailAddress>{{ emailaddress }}</EmailAddress>
<IncomingMailServerHostName>{{ connection_settings.imap.HOSTNAME }}</IncomingMailServerHostName>
<IncomingMailServerPortNumber>{{ connection_settings.imap.PORT }}</IncomingMailServerPortNumber>
<IncomingMailServerUseSSL>true</IncomingMailServerUseSSL>
<IncomingMailServerUsername>{{ emailaddress }}</IncomingMailServerUsername>
<OutgoingMailServerHostName>{{ connection_settings.smtp.HOSTNAME }}</OutgoingMailServerHostName>
<OutgoingMailServerPortNumber>{{ connection_settings.smtp.PORT }}</OutgoingMailServerPortNumber>
<OutgoingMailServerUseSSL>true</OutgoingMailServerUseSSL>
<OutgoingMailServerUsername>{{ emailaddress }}</OutgoingMailServerUsername>
<OutgoingPasswordSameAsIncomingPassword>true</OutgoingPasswordSameAsIncomingPassword>
</dict>
</payloadContent>
<PayloadOrganization>Modoboa</PayloadOrganization>
<PayloadDisplayName>Mail Configuration</PayloadDisplayName>
<PayloadIdentifier>{{ reverse_domain }}.mailconfig</PayloadIdentifier>
<PayloadUUID>{{ global_uuid }}</PayloadUUID>
<PayloadVersion>1</PayloadVersion>
<PayloadType>Configuration</PayloadType>
</dict>
</plist>
+38
View File
@@ -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'<emailProvider id="test.com">', 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"<LoginName>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"<EmailAddress>test@test.com", resp.content)
+21
View File
@@ -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"),
]
+86
View File
@@ -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
@@ -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',
+1
View File
@@ -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(),
+11
View File
@@ -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",