diff --git a/README.rst b/README.rst index 00b742b59ba73a287ada145150948580975a9c86..64f3c75b25e236c829c0c9cdf06cabf90e3d45b1 100644 --- a/README.rst +++ b/README.rst @@ -1,5 +1,5 @@ AlekSIS (School Information System) — App Postbuero (Mail server management) -================================================================================================== +============================================================================ AlekSIS ------- @@ -9,7 +9,20 @@ This is an application for use with the `AlekSIS®`_ platform. Features -------- -The author of this app did not describe it yet. +Postbuero provides integration with various mail server functionality, among which are: + + * Management of supported mail domains + * Management of mail addresses (mailboxes) for persons + + * Public registration for domains allowing it + + * Management of mail addresses (aliases) for groups + + * Including support for members, owners, and guardians + + * `WebMilter`_ support for Postfix + + * Alias resolution for persons and groups Licence ------- @@ -17,6 +30,7 @@ Licence :: Copyright © 2020 Tom Teichler <tom.teichler@teckids.org> + Copyright © 2022 Tom Teichler <tom.teichler@teckids.org> Licenced under the EUPL, version 1.2 or later @@ -33,5 +47,6 @@ by Teckids e.V. Please refer to the `trademark policy`_ for hints on using the t AlekSIS®. .. _AlekSIS®: https://edugit.org/AlekSIS/official/AlekSIS +.. _WebMilter: https://docs.bergblau.io/concepts/webmilter/ .. _European Union Public Licence: https://eupl.eu/ .. _trademark policy: https://aleksis.org/pages/about diff --git a/aleksis/apps/postbuero/apps.py b/aleksis/apps/postbuero/apps.py index 881bbc8369446e6bae9f17a26fe6a5254d9933d7..527488b3c49433a9ff6cc8fac6dcb8de02af60b7 100644 --- a/aleksis/apps/postbuero/apps.py +++ b/aleksis/apps/postbuero/apps.py @@ -1,5 +1,11 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ + from aleksis.core.util.apps import AppConfig +from django.apps import apps +from django.db.models import functions + class PostBueroConfig(AppConfig): name = "aleksis.apps.postbuero" @@ -9,4 +15,33 @@ class PostBueroConfig(AppConfig): "Repository": "https://edugit.org/AlekSIS/Onboarding/AlekSIS-App-Postbuero", } licence = "EUPL-1.2+" - copyright_info = (([2021], "Tom Teichler", "tom.teichler@teckids.org"),) + copyright_info = ( + ([2021], "Jonathan Weth", "dev@jonathanweth.de"), + ([2021], "Tom Teichler", "tom.teichler@teckids.org"), + ([2022], "Dominik George", "dominik.george@teckids.org"), + ) + + @classmethod + def get_all_scopes(cls) -> dict[str, str]: + """Return all OAuth scopes and their descriptions for this app.""" + MailDomain = apps.get_model("postbuero", "MailDomain") + scopes = {} + + label_prefix_webmilter = _("Use WebMilter APIs for domain") + scopes_webmilter = dict( + MailDomain.objects.annotate( + scope=functions.Concat( + models.Value(f"{MailDomain.SCOPE_PREFIX_WEBMILTER}_"), + models.F("domain"), + output_field=models.CharField(), + ), + label=functions.Concat( + models.Value(f"{label_prefix_webmilter}: "), models.F("domain") + ), + ) + .values_list("scope", "label") + .distinct() + ) + scopes.update(scopes_webmilter) + + return scopes diff --git a/aleksis/apps/postbuero/migrations/0005_group_address.py b/aleksis/apps/postbuero/migrations/0005_group_address.py new file mode 100644 index 0000000000000000000000000000000000000000..a226e3b2e5ae5dd70fad3acdb80d93ddeb898f49 --- /dev/null +++ b/aleksis/apps/postbuero/migrations/0005_group_address.py @@ -0,0 +1,70 @@ +# Generated by Django 3.2.15 on 2022-08-10 16:29 + +import aleksis.core.managers +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('sites', '0002_alter_domain_unique'), + ('core', '0041_update_gender_choices'), + ('postbuero', '0004_domain_is_public'), + ] + + operations = [ + migrations.CreateModel( + name='MailAlias', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('extended_data', models.JSONField(default=dict, editable=False)), + ('local_part', models.CharField(max_length=64, verbose_name='Local part')), + ('domain', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='postbuero.maildomain', verbose_name='Domain')), + ('polymorphic_ctype', models.ForeignKey(editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='polymorphic_postbuero.mailalias_set+', to='contenttypes.contenttype')), + ('site', models.ForeignKey(default=1, editable=False, on_delete=django.db.models.deletion.CASCADE, to='sites.site')), + ], + options={ + 'verbose_name': 'Mail alias', + 'verbose_name_plural': 'Mail alias', + }, + managers=[ + ('objects', aleksis.core.managers.PolymorphicCurrentSiteManager()), + ], + ), + migrations.CreateModel( + name='MailAliasForPerson', + fields=[ + ('mailalias_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='postbuero.mailalias')), + ('person', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='local_mail_aliases', to='core.person', verbose_name='Person')), + ], + options={ + 'verbose_name': 'Mail alias for a person', + 'verbose_name_plural': 'Mail aliases for persons', + }, + bases=('postbuero.mailalias',), + managers=[ + ('objects', aleksis.core.managers.PolymorphicCurrentSiteManager()), + ], + ), + migrations.CreateModel( + name='MailAliasForGroup', + fields=[ + ('mailalias_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='postbuero.mailalias')), + ('group', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='local_mail_aliases', to='core.group', verbose_name='Group')), + ], + options={ + 'verbose_name': 'Mail alias for a group', + 'verbose_name_plural': 'Mail aliases for groups', + }, + bases=('postbuero.mailalias',), + managers=[ + ('objects', aleksis.core.managers.PolymorphicCurrentSiteManager()), + ], + ), + migrations.AddConstraint( + model_name='mailalias', + constraint=models.UniqueConstraint(fields=('local_part', 'domain'), name='unique_alias_per_domain'), + ), + ] diff --git a/aleksis/apps/postbuero/models.py b/aleksis/apps/postbuero/models.py index 4fedd1863a4f4da48f023334c09e0bf2c570e684..5e6844c541ffde7adb1aa770987151d4fd43f16f 100644 --- a/aleksis/apps/postbuero/models.py +++ b/aleksis/apps/postbuero/models.py @@ -1,12 +1,14 @@ from django.db import models from django.utils.translation import gettext_lazy as _ -from aleksis.core.mixins import ExtensibleModel -from aleksis.core.models import Person +from aleksis.core.mixins import ExtensibleModel, ExtensiblePolymorphicModel +from aleksis.core.models import Group, Person class MailDomain(ExtensibleModel): + SCOPE_PREFIX_WEBMILTER = "maildomain" + domain = models.CharField(verbose_name=_("Domain"), max_length=255) is_public = models.BooleanField(verbose_name=_("Public usable"), default=True) @@ -14,6 +16,11 @@ class MailDomain(ExtensibleModel): def __str__(self) -> str: return self.domain + @property + def scope_webmilter(self) -> str: + """Return OAuth2 scope name to use WebMilter API.""" + return f"{self.SCOPE_PREFIX_WEBMILTER}_{self.domain}" + class Meta: permissions = (("can_use_domain", _("Can use domain")),) @@ -40,3 +47,74 @@ class MailAddress(ExtensibleModel): fields=["local_part", "domain"], name="unique_local_part_per_domain" ) ] + + +class MailAlias(ExtensiblePolymorphicModel): + domain = models.ForeignKey(MailDomain, verbose_name=_("Domain"), on_delete=models.CASCADE) + local_part = models.CharField(verbose_name=_("Local part"), max_length=64) + + def __str__(self) -> str: + return f"{self.local_part}@{self.domain}" + + def resolve(self, **kwargs): + raise NotImplementedError("You must use the concrete model to resovle the alias.") + + class Meta: + verbose_name = _("Mail alias") + verbose_name_plural = _("Mail alias") + constraints = [ + models.UniqueConstraint(fields=["local_part", "domain"], name="unique_alias_per_domain") + ] + + +class MailAliasForPerson(MailAlias): + person = models.ForeignKey( + Person, + verbose_name=_("Person"), + null=True, + on_delete=models.SET_NULL, + related_name="local_mail_aliases", + ) + + def resolve(self, guardians: bool = False, **kwargs): + """Resolve alias to the e-mail address of this person or its guardians.""" + if not self.person: + return [] + + if guardians: + return list(self.person.guardians.all().values_list("email", flat=True)) + + return [self.person.email] + + class Meta: + verbose_name = _("Mail alias for a person") + verbose_name_plural = _("Mail aliases for persons") + + +class MailAliasForGroup(MailAlias): + group = models.ForeignKey( + Group, + verbose_name=_("Group"), + null=True, + on_delete=models.SET_NULL, + related_name="local_mail_aliases", + ) + + def resolve(self, guardians: bool = False, owners: bool = False, **kwargs): + """Resolve alias to the e-mail addresses of this group's members, owners, or their guardians.""" + if not self.group: + return [] + + if owners: + pq = self.group.owners.all() + else: + pq = self.group.members.all() + + if guardians: + pq = Person.objects.filter(children__in=pq) + + return list(pq.values_list("email", flat=True)) + + class Meta: + verbose_name = _("Mail alias for a group") + verbose_name_plural = _("Mail aliases for groups") diff --git a/aleksis/apps/postbuero/urls.py b/aleksis/apps/postbuero/urls.py index 29f230eb27091f628ccea9505c9bd6d8fe7f3977..6288a09dfd94bab218df826f2a61083a0215ff34 100644 --- a/aleksis/apps/postbuero/urls.py +++ b/aleksis/apps/postbuero/urls.py @@ -1,16 +1,20 @@ from django.urls import include, path -from rest_framework import routers - from . import views -router = routers.DefaultRouter() -router.register(r"mails", views.MailAddressViewSet, basename="MailAddress") - urlpatterns = [ path("mails/manage/", views.ManageMail.as_view(), name="manage_mail"), path("mail_domains/", views.MailDomainListView.as_view(), name="mail_domains"), path("mail_domains/create/", views.MailDomainCreateView.as_view(), name="create_mail_domain"), path("mail_domains/<int:pk>/", views.MailDomainEditView.as_view(), name="edit_mail_domain"), - path("api/", include(router.urls)), + path( + "api/webmilter/alias/<str:address>/", + views.WebMilterAliasView.as_view(), + name="webmilter_resolveAliasByAddress", + ), + path( + "api/webmilter/domain/<str:domain>/alias/<str:local>/", + views.WebMilterAliasView.as_view(), + name="webmilter_resolveAliasByDomainAndLocalpart", + ), ] diff --git a/aleksis/apps/postbuero/views.py b/aleksis/apps/postbuero/views.py index c18a70bc7b77de087dafa322e045884b098a9e37..14d28d3cc463106708346b98c79884b64967f946 100644 --- a/aleksis/apps/postbuero/views.py +++ b/aleksis/apps/postbuero/views.py @@ -1,4 +1,5 @@ from django.conf import settings +from django.core.exceptions import BadRequest from django.http import HttpRequest, HttpResponse from django.shortcuts import render from django.urls import reverse_lazy @@ -9,18 +10,20 @@ from django.views.generic.base import View import reversion from django_tables2 import SingleTableView -from rest_framework import viewsets +from oauth2_provider.views.mixins import ScopedResourceMixin +from rest_framework.generics import RetrieveAPIView +from rest_framework.response import Response from rules.contrib.views import PermissionRequiredMixin from aleksis.core.mixins import AdvancedCreateView, AdvancedEditView from aleksis.core.models import Activity from aleksis.core.util import messages +from aleksis.core.util.auth_helpers import ClientProtectedResourceMixin from aleksis.core.util.core_helpers import get_site_preferences from aleksis.core.util.email import send_email from .forms import MailAddForm, MailDomainForm -from .models import MailAddress, MailDomain -from .serializers import MailAddressSerializer +from .models import MailAddress, MailAlias, MailDomain from .tables import MailDomainTable @@ -115,8 +118,56 @@ class MailDomainEditView(PermissionRequiredMixin, AdvancedEditView): success_message = _("The mail domain has been saved.") -class MailAddressViewSet(viewsets.ModelViewSet): - serializer_class = MailAddressSerializer +class WebMilterDomainMixin(ScopedResourceMixin, ClientProtectedResourceMixin): + """Base view for WebMilter domain operations.""" - def get_queryset(self): - return MailAddress.objects.all() + def _get_address_parts(self): + if "local" in self.kwargs: + local_part, domain = self.kwargs["local"], self.kwargs["domain"] + elif "address" in self.kwargs: + if "@" not in self.kwargs["address"]: + raise BadRequest(f"E-mail address {self.kwargs['address']} is malformed") + local_part, domain = self.kwargs["address"].split("@") + + return local_part, domain + + def _get_domain(self): + """Get domain object by either address or domain only.""" + local_part, domain = self._get_address_parts() + + return MailDomain.objects.get(domain=domain) + + def get_scopes(self, *args, **kwargs) -> list[str]: + """Return the scope needed to access the domain.""" + return [self._get_domain().scope_webmilter] + + +class WebMilterAliasView(WebMilterDomainMixin, RetrieveAPIView): + """View to resolve an alias address using WebMilter.""" + + def get_object(self): + local_part, domain = self._get_address_parts() + local_part = local_part.split("+")[0] + + return MailAlias.objects.get(domain__domain=domain, local_part=local_part) + + def _resolve_args_from_local_part(self): + local_part, domain = self._get_address_parts() + + args = {} + + if "+" not in local_part: + return args + + extension = local_part.split("+")[1] + mods = list(extension) + if "g" in mods: + args["guardians"] = True + if "o" in mods: + args["owners"] = True + + return args + + def retrieve(self, request, *args, **kwargs): + args = self._resolve_args_from_local_part() + return Response(self.get_object().resolve(**args))