diff --git a/CHANGELOG.rst b/CHANGELOG.rst
index cc2f19ad963e92513d157856c32279b5c5b5cdf5..66ae876f1e2d31f45635ed1409c0ebd54b7d4d47 100644
--- a/CHANGELOG.rst
+++ b/CHANGELOG.rst
@@ -65,6 +65,7 @@ Added
 * Introduce .well-known urlpatterns for apps
 * Global school term select for limiting data to a specific school term.
 * [Dev] Notifications based on calendar alarms.
+* CalDAV and CardDAV for syncing calendars and Persons read-only.
 
 Changed
 ~~~~~~~
diff --git a/aleksis/core/apps.py b/aleksis/core/apps.py
index ccaa9e948707884235309cb80406662a61acdb5d..2b512384a8011b0656568e37bbe69c0e13a9db15 100644
--- a/aleksis/core/apps.py
+++ b/aleksis/core/apps.py
@@ -62,7 +62,7 @@ class CoreConfig(AppConfig):
         from django.conf import settings  # noqa
 
         # Autodiscover various modules defined by AlekSIS
-        autodiscover_modules("model_extensions", "form_extensions", "checks")
+        autodiscover_modules("model_extensions", "form_extensions", "checks", "util.dav_handler")
 
         personpreferencemodel = self.get_model("PersonPreferenceModel")
         grouppreferencemodel = self.get_model("GroupPreferenceModel")
diff --git a/aleksis/core/managers.py b/aleksis/core/managers.py
index fccc79daae0e5267ef7912a8ae62850dc00c2a20..8ab80c9c4d6bda706b80f5477738bae7e727ab19 100644
--- a/aleksis/core/managers.py
+++ b/aleksis/core/managers.py
@@ -47,12 +47,20 @@ class PolymorphicBaseManager(AlekSISBaseManagerWithoutMigrations, PolymorphicMan
     """Default manager for extensible, polymorphic models."""
 
 
-class RecurrencePolymorphicQuerySet(PolymorphicQuerySet, CTEQuerySet):
+class CalendarEventMixinQuerySet(CTEQuerySet):
     pass
 
 
-class RecurrencePolymorphicManager(PolymorphicBaseManager, RecurrenceManager):
-    queryset_class = RecurrencePolymorphicQuerySet
+class CalendarEventQuerySet(PolymorphicQuerySet, CalendarEventMixinQuerySet):
+    pass
+
+
+class CalendarEventMixinManager(RecurrenceManager):
+    queryset_class = CalendarEventMixinQuerySet
+
+
+class CalendarEventManager(PolymorphicBaseManager, CalendarEventMixinManager):
+    queryset_class = CalendarEventQuerySet
 
 
 class DateRangeQuerySetMixin:
@@ -211,7 +219,7 @@ class InstalledWidgetsDashboardWidgetOrderManager(Manager):
         return super().get_queryset().filter(widget_id__in=dashboard_widget_pks)
 
 
-class HolidayQuerySet(DateRangeQuerySetMixin, RecurrencePolymorphicQuerySet):
+class HolidayQuerySet(DateRangeQuerySetMixin, CalendarEventQuerySet):
     """QuerySet with custom query methods for holidays."""
 
     def get_all_days(self) -> list[date]:
@@ -222,5 +230,5 @@ class HolidayQuerySet(DateRangeQuerySetMixin, RecurrencePolymorphicQuerySet):
         return holiday_days
 
 
-class HolidayManager(RecurrencePolymorphicManager):
+class HolidayManager(CalendarEventManager):
     queryset_class = HolidayQuerySet
diff --git a/aleksis/core/migrations/0072_birthdayevent_view.py b/aleksis/core/migrations/0072_birthdayevent_view.py
new file mode 100644
index 0000000000000000000000000000000000000000..f0952560f802ddc5c897c8234a9b510da5eb127e
--- /dev/null
+++ b/aleksis/core/migrations/0072_birthdayevent_view.py
@@ -0,0 +1,26 @@
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0071_constrain_calendar_event_starting_before_ending'),
+        migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+    ]
+
+    operations = [
+        migrations.RunSQL(
+            """
+            -- Create view for BirthdayEvents
+            CREATE VIEW core_birthdayevent AS
+                SELECT id,
+                id AS person_id,
+                date_of_birth AS dt_start,
+                'YEARLY' AS rrule
+                FROM core_person
+                WHERE date_of_birth IS NOT NULL;
+            """
+        ),
+    ]
diff --git a/aleksis/core/mixins.py b/aleksis/core/mixins.py
index fcba78889865e00191e81df9cbe771ec54be8e29..38f2a970a34fa22af3fffe72f4412674293d6f1a 100644
--- a/aleksis/core/mixins.py
+++ b/aleksis/core/mixins.py
@@ -1,19 +1,23 @@
 # flake8: noqa: DJ12
 
 import os
-from collections.abc import Iterable
+import warnings
+from collections.abc import Iterable, Sequence
 from datetime import datetime
+from hashlib import sha256
 from typing import TYPE_CHECKING, Any, Callable, ClassVar, Optional, Union
+from xml.etree import ElementTree
 
 from django.conf import settings
 from django.contrib import messages
 from django.contrib.auth.views import LoginView, RedirectURLMixin
 from django.db import models
-from django.db.models import JSONField, QuerySet
+from django.db.models import JSONField, Q, QuerySet
 from django.db.models.fields import CharField, TextField
 from django.forms.forms import BaseForm
 from django.forms.models import ModelForm, ModelFormMetaclass, fields_for_model
 from django.http import HttpRequest, HttpResponse
+from django.urls import reverse
 from django.utils import timezone
 from django.utils.functional import classproperty, lazy
 from django.utils.translation import gettext as _
@@ -52,8 +56,8 @@ class _ExtensibleModelBase(models.base.ModelBase):
      - Register all AlekSIS models with django-reversion
     """
 
-    def __new__(mcls, name, bases, attrs):
-        mcls = super().__new__(mcls, name, bases, attrs)
+    def __new__(mcls, name, bases, attrs, **kwargs):
+        mcls = super().__new__(mcls, name, bases, attrs, **kwargs)
 
         if "Meta" not in attrs or not attrs["Meta"].abstract:
             # Register all non-abstract models with django-reversion
@@ -491,10 +495,21 @@ class RegistryObject:
     _registry: ClassVar[Optional[dict[str, type["RegistryObject"]]]] = None
     name: ClassVar[str] = ""
 
-    def __init_subclass__(cls):
-        if getattr(cls, "_registry", None) is None:
+    def __init_subclass__(cls, is_registry=False, register=True):
+        parent_registry = getattr(cls, "_registry", None)
+        cls._is_registry = False
+
+        if parent_registry is None or is_registry:
             cls._registry = {}
-        else:
+            cls._is_registry = True
+
+            if getattr(cls, "_parent_registries", None) is None:
+                cls._parent_registries = []
+
+            if parent_registry is not None:
+                cls._parent_registries.append(parent_registry)
+
+        elif register:
             if not cls.name:
                 cls.name = cls.__name__
             cls._register()
@@ -504,28 +519,158 @@ class RegistryObject:
         if cls.name and cls.name not in cls._registry:
             cls._registry[cls.name] = cls
 
+            for registry in cls._parent_registries:
+                registry[cls.name] = cls
+
+    @classmethod
+    def get_registry_objects(cls) -> dict[str, type["RegistryObject"]]:
+        return cls._registry
+
+    @classmethod
+    def get_registry_objects_recursive(cls) -> dict[str, type["RegistryObject"]]:
+        objs = cls._registry.copy()
+        for sub_registry in cls.get_sub_registries().values():
+            objs |= sub_registry.get_registry_objects_recursive()
+        return objs
+
+    @classmethod
+    def get_sub_registries(cls) -> dict[str, type["RegistryObject"]]:
+        registries = {}
+        for registry in cls.__subclasses__():
+            if registry._registry:
+                registries[registry.name] = registry
+        return registries
+
+    @classmethod
+    def get_sub_registries_recursive(cls) -> dict[str, type["RegistryObject"]]:
+        registries = {}
+        for sub_registry in cls.get_sub_registries().values():
+            registries |= sub_registry.get_sub_registries()
+        return registries
+
     @classproperty
     def registered_objects_dict(cls) -> dict[str, type["RegistryObject"]]:
         """Get dict of registered objects."""
-        return cls._registry
+        return cls.get_registry_objects()
 
     @classproperty
     def registered_objects_list(cls) -> list[type["RegistryObject"]]:
         """Get list of registered objects."""
-        return list(cls._registry.values())
+        return list(cls.get_registry_objects().values())
 
     @classmethod
     def get_object_by_name(cls, name: str) -> Optional[type["RegistryObject"]]:
         """Get registered object by name."""
         return cls.registered_objects_dict.get(name)
 
+    @classmethod
+    def get_sub_registry_by_name(cls, name: str) -> Optional[type["RegistryObject"]]:
+        return cls.get_sub_registries().get(name)
+
 
 class ObjectAuthenticator(RegistryObject):
     def authenticate(self, request, obj):
         raise NotImplementedError()
 
 
-class CalendarEventMixin(RegistryObject):
+class DAVResource(RegistryObject):
+    """Mixin for objects to provide via DAV."""
+
+    dav_verbose_name: ClassVar[str] = ""
+    dav_content_type: ClassVar[str] = ""
+
+    dav_ns: ClassVar[dict[str, str]] = {}
+    dav_resource_types: ClassVar[list[str]] = []
+
+    # Hint: We do not support dead properties for now
+    dav_live_props: ClassVar[list[tuple[str, str]]] = [
+        ("DAV:", "displayname"),
+        ("DAV:", "resourcetype"),
+        ("DAV:", "getcontenttype"),
+        ("DAV:", "getcontentlength"),
+    ]
+    dav_live_prop_methods: ClassVar[dict[tuple[str, str], str]] = {}
+
+    @classmethod
+    def get_dav_verbose_name(cls, request: Optional[HttpRequest] = None) -> str:
+        """Return the verbose name of the calendar feed."""
+        return str(cls.dav_verbose_name)
+
+    @classmethod
+    def _register_dav_ns(cls):
+        for prefix, url in cls.dav_ns.items():
+            ElementTree.register_namespace(prefix, url)
+        ElementTree.register_namespace("d", "DAV:")
+
+    @classmethod
+    def _add_dav_propnames(cls, prop: ElementTree.SubElement) -> None:
+        for ns, propname in cls.dav_live_props:
+            ElementTree.SubElement(prop, f"{{{ns}}}{propname}")
+
+    @classmethod
+    def get_dav_absolute_url(cls, reference_object, request: HttpRequest) -> str:
+        raise NotImplementedError
+
+    @classmethod
+    def get_dav_file_content(
+        cls,
+        request: HttpRequest,
+        objects: Optional[Iterable | QuerySet] = None,
+        params: Optional[dict[str, any]] = None,
+    ) -> str:
+        raise NotImplementedError
+
+    @classmethod
+    def get_dav_content_type(cls) -> str:
+        return cls.dav_content_type
+
+    @classmethod
+    def getetag(cls, request: HttpRequest, objects) -> str:
+        warnings.warn(
+            f"""The class {cls.__name__} does not override the getetag method and uses the slow and potentially unstable default implementation."""  # noqa: E501
+        )
+        try:
+            content = cls.get_dav_file_content(request, objects)
+        except NotImplementedError:
+            content = b""
+        etag = sha256()
+        etag.update(content)
+        digest = etag.hexdigest()
+        return digest
+
+
+class ContactMixin(DAVResource, RegistryObject, is_registry=True):
+    name: ClassVar[str] = "contact"  # Unique name for the calendar feed
+    verbose_name: ClassVar[str] = ""  # Shown name of the feed
+    link: ClassVar[str] = ""  # Link for the feed, optional
+    description: ClassVar[str] = ""  # Description of the feed, optional
+    color: ClassVar[str] = "#222222"  # Color of the feed, optional
+    permission_required: ClassVar[str] = ""
+
+    dav_ns = {
+        "carddav": "urn:ietf:params:xml:ns:carddav",
+    }
+    dav_resource_types = ["{urn:ietf:params:xml:ns:carddav}addressbook"]
+    dav_content_type = "text/vcard"
+
+    dav_live_props: ClassVar[list[tuple[str, str]]] = [
+        ("DAV:", "displayname"),
+        ("DAV:", "resourcetype"),
+        ("DAV:", "getcontenttype"),
+        ("DAV:", "getcontentlength"),
+        (dav_ns["carddav"], "addressbook-description"),
+    ]
+
+    @classmethod
+    def get_dav_absolute_url(cls, reference_object, request: HttpRequest) -> str:
+        return reverse("dav_resource_contact", args=["contact", cls.name, reference_object.id])
+
+    @classmethod
+    def value_unique_id(cls, reference_object, request: HttpRequest) -> str:
+        return f"{cls.name}-{reference_object.id}"
+
+
+class CalendarEventMixin(DAVResource, RegistryObject, is_registry=True):
     """Mixin for calendar feeds.
 
     This mixin can be used to create calendar feeds for objects. It can be used
@@ -562,12 +707,28 @@ class CalendarEventMixin(RegistryObject):
     for the attribute. The method is called with the reference object as argument.
     """
 
-    name: str = ""  # Unique name for the calendar feed
-    verbose_name: str = ""  # Shown name of the feed
-    link: str = ""  # Link for the feed, optional
-    description: str = ""  # Description of the feed, optional
-    color: str = "#222222"  # Color of the feed, optional
-    permission_required: str = ""
+    name: ClassVar[str] = "calendar"  # Unique name for the calendar feed
+    verbose_name: ClassVar[str] = ""  # Shown name of the feed
+    link: ClassVar[str] = ""  # Link for the feed, optional
+    description: ClassVar[str] = ""  # Description of the feed, optional
+    color: ClassVar[str] = "#222222"  # Color of the feed, optional
+    permission_required: ClassVar[str] = ""
+
+    dav_ns = {
+        "cal": "urn:ietf:params:xml:ns:caldav",
+        "ical": "http://apple.com/ns/ical/",
+    }
+    dav_resource_types = ["{urn:ietf:params:xml:ns:caldav}calendar"]
+    dav_content_type = "text/calendar; charset=utf8; component=vevent"
+
+    dav_live_props: ClassVar[list[tuple[str, str]]] = [
+        ("DAV:", "displayname"),
+        ("DAV:", "resourcetype"),
+        ("DAV:", "getcontenttype"),
+        ("DAV:", "getcontentlength"),
+        (dav_ns["ical"], "calendar-color"),
+        (dav_ns["cal"], "calendar-description"),
+    ]
 
     @classmethod
     def get_verbose_name(cls, request: Optional[HttpRequest] = None) -> str:
@@ -617,6 +778,7 @@ class CalendarEventMixin(RegistryObject):
         """Create an event for the given reference object and add it to the feed."""
         values = {}
         values["timestamp"] = timezone.now()
+        values["description"] = None
         for field in cls.get_event_field_names():
             field_value = cls.get_event_field_value(
                 reference_object, field, request=request, params=params
@@ -643,13 +805,42 @@ class CalendarEventMixin(RegistryObject):
     @classmethod
     def get_objects(
         cls,
-        request: Optional[HttpRequest] = None,
-        params: Optional[dict[str, any]] = None,
+        request: HttpRequest | None = None,
+        params: dict[str, any] | None = None,
         start: Optional[datetime] = None,
         end: Optional[datetime] = None,
-    ) -> Iterable:
-        """Return the objects to create the calendar feed for."""
-        raise NotImplementedError
+        start_qs: QuerySet | None = None,
+        additional_filter: Q | None = None,
+        select_related: Sequence | None = None,
+        prefetch_related: Sequence | None = None,
+        expand_start: Optional[datetime] = None,
+        expand_end: Optional[datetime] = None,
+        expand: bool = False,
+    ) -> QuerySet:
+        """Return all objects that should be included in the calendar."""
+        qs = cls.objects if start_qs is None else start_qs
+        if isinstance(qs, PolymorphicBaseManager):
+            qs = qs.instance_of(cls)
+        if not start or not end:
+            if additional_filter is not None:
+                qs = qs.filter(additional_filter)
+            if select_related is not None:
+                qs = qs.select_related(*select_related)
+            if prefetch_related is not None:
+                qs = qs.prefetch_related(*prefetch_related)
+        else:
+            qs = cls.objects.with_occurrences(
+                start,
+                end,
+                start_qs=qs,
+                additional_filter=additional_filter,
+                select_related=select_related,
+                prefetch_related=prefetch_related,
+                expand_start=expand_start,
+                expand_end=expand_end,
+                expand=expand,
+            )
+        return qs
 
     @classmethod
     def create_feed(
@@ -658,7 +849,7 @@ class CalendarEventMixin(RegistryObject):
         params: Optional[dict[str, any]] = None,
         start: Optional[datetime] = None,
         end: Optional[datetime] = None,
-        queryset: Optional[QuerySet] = None,
+        queryset: Optional[Iterable | QuerySet] = None,
         **kwargs,
     ) -> ExtendedICal20Feed:
         """Create the calendar feed with all events."""
@@ -685,7 +876,7 @@ class CalendarEventMixin(RegistryObject):
     ) -> Calendar:
         """Return the calendar object."""
         feed = cls.create_feed(request=request, params=params, queryset=queryset)
-        return feed.get_calendar_object()
+        return feed.get_calendar_object(params=params)
 
     @classmethod
     def get_events(
@@ -703,7 +894,7 @@ class CalendarEventMixin(RegistryObject):
         feed = cls.create_feed(
             request=request, params=params, start=start, end=end, queryset=queryset, **kwargs
         )
-        return feed.get_calendar_object(with_reference_object=with_reference_object)
+        return feed.get_calendar_object(with_reference_object=with_reference_object, params=params)
 
     @classmethod
     def get_single_events(
@@ -777,3 +968,23 @@ class CalendarEventMixin(RegistryObject):
     @classmethod
     def get_enabled_feeds(cls, request: HttpRequest | None = None):
         return [feed for feed in cls.valid_feeds if feed.get_enabled(request)]
+
+    @classmethod
+    def get_dav_verbose_name(cls, request: Optional[HttpRequest] = None) -> str:
+        return str(cls.get_verbose_name())
+
+    @classmethod
+    def get_dav_file_content(
+        cls,
+        request: HttpRequest,
+        objects: Optional[Iterable | QuerySet] = None,
+        params: Optional[dict[str, any]] = None,
+        expand_start: datetime | None = None,
+        expand_end: datetime | None = None,
+    ):
+        feed = cls.create_feed(request, queryset=objects, params=params)
+        return feed.to_ical(params=params)
+
+    @classmethod
+    def get_dav_absolute_url(cls, reference_object, request: HttpRequest) -> str:
+        return reverse("dav_resource_calendar", args=["calendar", cls.name, reference_object.id])
diff --git a/aleksis/core/models.py b/aleksis/core/models.py
index f1b5ca2cb1a04e1bf8b36ad8aa500044070ab1ad..4f20856422c281e1954ff5364bea7aec751335da 100644
--- a/aleksis/core/models.py
+++ b/aleksis/core/models.py
@@ -62,18 +62,20 @@ from aleksis.core.data_checks import (
 
 from .managers import (
     AlekSISBaseManagerWithoutMigrations,
+    CalendarEventManager,
+    CalendarEventMixinManager,
     GroupManager,
     GroupQuerySet,
     HolidayManager,
     InstalledWidgetsDashboardWidgetOrderManager,
     PersonManager,
     PersonQuerySet,
-    RecurrencePolymorphicManager,
     SchoolTermQuerySet,
     UninstallRenitentPolymorphicManager,
 )
 from .mixins import (
     CalendarEventMixin,
+    ContactMixin,
     ExtensibleModel,
     ExtensiblePolymorphicModel,
     GlobalPermissionModel,
@@ -172,13 +174,15 @@ class SchoolTerm(ExtensibleModel):
         ordering = ["-date_start"]
 
 
-class Person(ExtensibleModel):
+class Person(ContactMixin, ExtensibleModel):
     """Person model.
 
     A model describing any person related to a school, including, but not
     limited to, students, teachers and guardians (parents).
     """
 
+    name = "person"
+
     objects = PersonManager.from_queryset(PersonQuerySet)()
 
     class Meta:
@@ -203,6 +207,7 @@ class Person(ExtensibleModel):
     icon_ = "account-outline"
 
     SEX_CHOICES = [("f", _("female")), ("m", _("male")), ("x", _("other"))]
+    SEX_CHOICES_VCARD = {"f": "F", "m": "M", "x": "O"}
 
     user = models.OneToOneField(
         get_user_model(),
@@ -384,6 +389,114 @@ class Person(ExtensibleModel):
         vcal.params["ROLE"] = vText(role)
         return vcal
 
+    def _is_unrequested_prop(self, efield, params):
+        """Return True if specific fields are requested and efield is not one of those.
+        If no fields are specified or efield is requested, return False"""
+
+        comp_name = "VCARD"
+
+        return (
+            params is not None
+            and comp_name in params
+            and efield is not None
+            and efield.upper() not in params[comp_name]
+        )
+
+    def as_vcard(self, request, params) -> str:
+        """Get this person as vCard.
+
+        Uses old version of vCard (3.0) thanks to chaotic breakage in Evolution.
+        """
+
+        card = [
+            "BEGIN:VCARD",
+            "VERSION:3.0",
+            "KIND:individual",
+            "PRODID:-//AlekSIS//AlekSIS//EN",
+        ]
+
+        if not self._is_unrequested_prop("UID", params):
+            # FIXME replace with UUID once implemented
+            card.append(f"UID:{request.build_absolute_uri(self.get_absolute_url())}")
+
+        # Name
+        if not self._is_unrequested_prop("FN", params):
+            card.append(f"FN:{self.addressing_name}")
+
+        if not self._is_unrequested_prop("N", params):
+            card.append(f"N:{self.last_name};{self.first_name};{self.additional_name};;")
+
+        # Birthday
+        if (
+            not self._is_unrequested_prop("BDAY", params)
+            and self.date_of_birth
+            and request.user.has_perm("core.view_personal_details", self)
+        ):
+            card.append(f"BDAY:{self.date_of_birth.isoformat()}")
+
+        # Email
+        if (
+            not self._is_unrequested_prop("EMAIL", params)
+            and self.email
+            and request.user.has_perm("core.view_contact_details", self)
+        ):
+            card.append(f"EMAIL:{self.email}")
+
+        # Phone Numbers
+        if not self._is_unrequested_prop("TEL", params):
+            if self.phone_number and request.user.has_perm("core.view_contact_details", self):
+                card.append(f"TEL;TYPE=home:{self.phone_number}")
+
+            if self.mobile_number and request.user.has_perm("core.view_contact_details", self):
+                card.append(f"TEL;TYPE=cell:{self.mobile_number}")
+
+        # Address
+        if (
+            not self._is_unrequested_prop("ADR", params)
+            and self.street
+            and self.postal_code
+            and self.place
+            and request.user.has_perm("core.view_address_rule", self)
+        ):
+            card.append(
+                f"ADR;TYPE=home:;;{self.street} {self.housenumber};"
+                f"{self.place};;{self.postal_code};"
+            )
+
+        card.append("END:VCARD")
+
+        return "\r\n".join(card) + "\r\n"
+
+    @classmethod
+    def get_objects(
+        cls,
+        request: HttpRequest | None = None,
+        start_qs: QuerySet | None = None,
+        additional_filter: Q | None = None,
+    ) -> QuerySet:
+        """Return all objects that should be included in the contact list."""
+        qs = cls.objects.all() if start_qs is None else start_qs
+        if request:
+            qs = qs.filter(
+                Q(pk=request.user.person.pk)
+                | Q(pk__in=get_objects_for_user(request.user, "core.view_personal_details", qs))
+            )
+        return qs.filter(additional_filter) if additional_filter else qs
+
+    @classmethod
+    def get_dav_file_content(
+        cls,
+        request: HttpRequest,
+        objects: Optional[Iterable | QuerySet] = None,
+        params: Optional[dict[str, any]] = None,
+    ) -> str:
+        if objects is None:
+            objects = cls.get_objects(request)
+        content = ""
+        for person in objects:
+            content += person.as_vcard(request, params)
+        return content.encode()
+
     def save(self, *args, **kwargs):
         # Determine all fields that were changed since last load
         changed = self.user_info_tracker.changed()
@@ -1496,7 +1609,9 @@ class Room(ExtensibleModel):
         ]
 
 
-class CalendarEvent(CalendarEventMixin, ExtensiblePolymorphicModel, RecurrenceModel):
+class CalendarEvent(
+    CalendarEventMixin, ExtensiblePolymorphicModel, RecurrenceModel, register=False
+):
     """A planned event in a calendar.
 
     To make use of this model, you need to inherit from this model.
@@ -1512,7 +1627,7 @@ class CalendarEvent(CalendarEventMixin, ExtensiblePolymorphicModel, RecurrenceMo
     Please refer to the documentation of `CalendarEventMixin` for more information.
     """
 
-    objects = RecurrencePolymorphicManager()
+    objects = CalendarEventManager()
 
     datetime_start = models.DateTimeField(
         verbose_name=_("Start date and time"), null=True, blank=True
@@ -1642,34 +1757,6 @@ class CalendarEvent(CalendarEventMixin, ExtensiblePolymorphicModel, RecurrenceMo
             calendar_alarm.get_alarm(request) for calendar_alarm in reference_object.alarms.all()
         ]
 
-    @classmethod
-    def get_objects(
-        cls,
-        request: HttpRequest | None = None,
-        params: dict[str, any] | None = None,
-        start: Optional[datetime] = None,
-        end: Optional[datetime] = None,
-        start_qs: QuerySet | None = None,
-        additional_filter: Q | None = None,
-        select_related: Sequence | None = None,
-        prefetch_related: Sequence | None = None,
-    ) -> QuerySet:
-        """Return all objects that should be included in the calendar."""
-        start_qs = cls.objects if start_qs is None else start_qs
-        start_qs = start_qs.instance_of(cls)
-        if not start or not end:
-            start = timezone.now() - timedelta(days=50)
-            end = timezone.now() + timedelta(days=50)
-        qs = cls.objects.with_occurrences(
-            start,
-            end,
-            start_qs=start_qs,
-            additional_filter=additional_filter,
-            select_related=select_related,
-            prefetch_related=prefetch_related,
-        )
-        return qs
-
     def save(self, *args, **kwargs):
         if (
             self.datetime_start
@@ -1741,44 +1828,76 @@ class CalendarEvent(CalendarEventMixin, ExtensiblePolymorphicModel, RecurrenceMo
 
 
 class FreeBusy(CalendarEvent):
-    pass
+    name = ""
+
+    @classmethod
+    def value_title(cls, reference_object: "FreeBusy", request: HttpRequest | None = None) -> str:
+        return ""
 
 
-class BirthdayEvent(CalendarEventMixin):
+class BirthdayEvent(CalendarEventMixin, models.Model):
     """A calendar feed with all birthdays."""
 
     name = "birthdays"
     verbose_name = _("Birthdays")
     permission_required = "core.view_birthday_calendar"
 
+    person = models.ForeignKey(Person, on_delete=models.DO_NOTHING)
+
+    objects = CalendarEventMixinManager()
+
+    class Meta:
+        managed = False
+        db_table = "core_birthdayevent"
+
+    def __str__(self):
+        return self.value_title(self)
+
     @classmethod
-    def value_title(cls, reference_object: Person, request: HttpRequest | None = None) -> str:
-        return _("{}'s birthday").format(reference_object.addressing_name)
+    def value_title(
+        cls, reference_object: "BirthdayEvent", request: HttpRequest | None = None
+    ) -> str:
+        return _("{}'s birthday").format(reference_object.person.addressing_name)
 
     @classmethod
-    def value_description(cls, reference_object: Person, request: HttpRequest | None = None) -> str:
-        return f"{reference_object.addressing_name} \
-            was born on {date_format(reference_object.date_of_birth)}."
+    def value_description(
+        cls, reference_object: "BirthdayEvent", request: HttpRequest | None = None
+    ) -> str:
+        description = f"{reference_object.person.addressing_name} "
+        description += f"was born on {date_format(reference_object.person.date_of_birth)}."
+        return description
 
     @classmethod
     def value_start_datetime(
-        cls, reference_object: Person, request: HttpRequest | None = None
+        cls, reference_object: "BirthdayEvent", request: HttpRequest | None = None
+    ) -> date:
+        return reference_object.person.date_of_birth
+
+    @classmethod
+    def value_end_datetime(
+        cls, reference_object: "BirthdayEvent", request: HttpRequest | None = None
     ) -> date:
-        return reference_object.date_of_birth
+        return None
 
     @classmethod
-    def value_rrule(cls, reference_object: Person, request: HttpRequest | None = None) -> vRecur:
+    def value_rrule(
+        cls, reference_object: "BirthdayEvent", request: HttpRequest | None = None
+    ) -> vRecur:
         return build_rrule_from_text("FREQ=YEARLY")
 
     @classmethod
-    def value_unique_id(cls, reference_object: Person, request: HttpRequest | None = None) -> str:
-        return f"birthday-{reference_object.id}"
+    def value_unique_id(
+        cls, reference_object: "BirthdayEvent", request: HttpRequest | None = None
+    ) -> str:
+        return f"birthday-{reference_object.person.id}"
 
     @classmethod
-    def value_meta(cls, reference_object: Person, request: HttpRequest | None = None) -> dict:
+    def value_meta(
+        cls, reference_object: "BirthdayEvent", request: HttpRequest | None = None
+    ) -> dict:
         return {
-            "name": reference_object.addressing_name,
-            "date_of_birth": reference_object.date_of_birth.isoformat(),
+            "name": reference_object.person.addressing_name,
+            "date_of_birth": reference_object.person.date_of_birth.isoformat(),
         }
 
     @classmethod
@@ -1792,6 +1911,8 @@ class BirthdayEvent(CalendarEventMixin):
         params: dict[str, any] | None = None,
         start: Optional[datetime] = None,
         end: Optional[datetime] = None,
+        start_qs: QuerySet | None = None,
+        additional_filter: Q | None = None,
     ) -> QuerySet:
         qs = Person.objects.filter(date_of_birth__isnull=False)
         if request:
@@ -1799,7 +1920,14 @@ class BirthdayEvent(CalendarEventMixin):
                 Q(pk=request.user.person.pk)
                 | Q(pk__in=get_objects_for_user(request.user, "core.view_personal_details", qs))
             )
-        return qs
+
+        q = Q(person__in=qs)
+        if additional_filter is not None:
+            q = q & additional_filter
+
+        return super().get_objects(
+            request=request, params=params, start_qs=start_qs, additional_filter=q
+        )
 
 
 class Holiday(CalendarEvent):
@@ -1896,6 +2024,10 @@ class PersonalEvent(CalendarEvent):
     persons = models.ManyToManyField(Person, related_name="+", blank=True)
     groups = models.ManyToManyField(Group, related_name="+", blank=True)
 
+    @classmethod
+    def get_description(cls, request: HttpRequest | None = None) -> str:
+        return ""
+
     @classmethod
     def get_color(cls, request: HttpRequest | None = None) -> str:
         return get_site_preferences()["calendar__personal_event_color"]
@@ -1959,18 +2091,25 @@ class PersonalEvent(CalendarEvent):
         params: dict[str, any] | None = None,
         start: Optional[datetime] = None,
         end: Optional[datetime] = None,
+        start_qs: QuerySet | None = None,
         additional_filter: Q | None = None,
         **kwargs,
     ) -> QuerySet:
-        q = additional_filter
+        q = additional_filter if additional_filter is not None else Q()
         if request:
-            q = (
+            q = q & (
                 Q(owner=request.user.person)
                 | Q(persons=request.user.person)
                 | Q(groups__members=request.user.person)
             )
         qs = super().get_objects(
-            request=request, params=params, start=start, end=end, additional_filter=q, **kwargs
+            request=request,
+            params=params,
+            start=start,
+            end=end,
+            start_qs=start_qs,
+            additional_filter=q,
+            **kwargs,
         )
         return qs
 
diff --git a/aleksis/core/schema/calendar.py b/aleksis/core/schema/calendar.py
index d79c7c0f99274823f9b96f6a1239134e98069be1..80330b385db1a067714bd99e70d7eca297796b22 100644
--- a/aleksis/core/schema/calendar.py
+++ b/aleksis/core/schema/calendar.py
@@ -27,7 +27,7 @@ class CalendarEventType(ObjectType):
         return root["SUMMARY"]
 
     def resolve_description(root, info, **kwargs):
-        return root["DESCRIPTION"]
+        return root.get("DESCRIPTION", "")
 
     def resolve_location(root, info, **kwargs):
         return root.get("LOCATION", "")
@@ -39,7 +39,7 @@ class CalendarEventType(ObjectType):
         return root["DTEND"].dt
 
     def resolve_color(root, info, **kwargs):
-        return root["COLOR"]
+        return root.get("COLOR")
 
     def resolve_uid(root, info, **kwargs):
         return root["UID"]
@@ -88,7 +88,9 @@ class CalendarType(ObjectType):
         return root.get_description(info.context)
 
     def resolve_url(root, info, **kwargs):
-        return info.context.build_absolute_uri(reverse("calendar_feed", args=[root.name]))
+        return info.context.build_absolute_uri(
+            reverse("calendar_feed", args=["calendar", root.name])
+        )
 
     def resolve_color(root, info, **kwargs):
         return root.get_color(info.context)
@@ -129,4 +131,4 @@ class CalendarBaseType(ObjectType):
         return CalendarEventMixin.get_enabled_feeds(info.context)
 
     def resolve_all_feeds_url(root, info, **kwargs):
-        return info.context.build_absolute_uri(reverse("all_calendar_feeds"))
+        return info.context.build_absolute_uri(reverse("all_calendar_feeds", args=["calendar"]))
diff --git a/aleksis/core/tests/models/test_calendar_event.py b/aleksis/core/tests/models/test_calendar_event.py
index f26e7dcdeccdaaea29698bd1acfe1a120b800f83..e5e663601a8ab3edce70caf03e366d2b0889f1a6 100644
--- a/aleksis/core/tests/models/test_calendar_event.py
+++ b/aleksis/core/tests/models/test_calendar_event.py
@@ -1,8 +1,8 @@
 from datetime import datetime, timezone
-from zoneinfo import ZoneInfo
 
 import pytest
 from recurrence import WEEKLY, Recurrence, Rule
+from zoneinfo import ZoneInfo
 
 from aleksis.core.models import CalendarEvent
 
diff --git a/aleksis/core/urls.py b/aleksis/core/urls.py
index 2949d4fa0ce892caa7364f34e451c0af3985f7a5..16e290332c434115edca2870480e5ede0fca1e01 100644
--- a/aleksis/core/urls.py
+++ b/aleksis/core/urls.py
@@ -39,6 +39,16 @@ urlpatterns = [
         ConnectDiscoveryInfoView.as_view(),
         name="oidc_configuration",
     ),
+    path(
+        ".well-known/carddav/",
+        views.DAVWellKnownView.as_view(),
+        name="wellknown_carddav",
+    ),
+    path(
+        ".well-known/caldav/",
+        views.DAVWellKnownView.as_view(),
+        name="wellknown_caldav",
+    ),
     path("oauth/applications/", views.TemplateView.as_view(template_name="core/vue_index.html")),
     path(
         "oauth/applications/register/",
@@ -76,6 +86,27 @@ urlpatterns = [
         views.ObjectRepresentationView.as_view(),
         name="object_representation_anonymous",
     ),
+    path(
+        "feeds/<str:subregistry>/<str:name>.ics", views.ICalFeedView.as_view(), name="calendar_feed"
+    ),
+    path("feeds/<str:name>.ics", views.ICalAllFeedsView.as_view(), name="all_calendar_feeds"),
+    path("dav/", views.DAVResourceView.as_view(), name="dav_registry"),
+    path("dav/<str:name>/", views.DAVResourceView.as_view(), name="dav_subregistry"),
+    path(
+        "dav/<str:subregistry>/<str:name>/",
+        views.DAVResourceView.as_view(),
+        name="dav_resource",
+    ),
+    path(
+        "dav/<str:subregistry>/<str:name>/<int:id>.ics",
+        views.DAVSingleResourceView.as_view(),
+        name="dav_resource_calendar",
+    ),
+    path(
+        "dav/<str:subregistry>/<str:name>/<int:id>.vcf",
+        views.DAVSingleResourceView.as_view(),
+        name="dav_resource_contact",
+    ),
     path("", include("django_prometheus.urls")),
     path(
         "django/",
@@ -387,8 +418,6 @@ urlpatterns = [
                     views.AssignPermissionView.as_view(),
                     name="assign_permission",
                 ),
-                path("feeds/<str:name>.ics", views.ICalFeedView.as_view(), name="calendar_feed"),
-                path("feeds.ics", views.ICalAllFeedsView.as_view(), name="all_calendar_feeds"),
             ]
         ),
     ),
diff --git a/aleksis/core/util/auth_helpers.py b/aleksis/core/util/auth_helpers.py
index eaeb0030c0b9040b28d719b96af2d02f80aa7952..5c76860964077ae6fa588349084979c6d9b5e711 100644
--- a/aleksis/core/util/auth_helpers.py
+++ b/aleksis/core/util/auth_helpers.py
@@ -1,11 +1,13 @@
 """Helpers/overrides for django-allauth."""
 
+from base64 import b64decode
 from typing import Any, Optional
 
+from django.contrib.auth import authenticate
 from django.contrib.auth.validators import ASCIIUsernameValidator
 from django.core.exceptions import ValidationError
 from django.core.validators import RegexValidator
-from django.http import HttpRequest
+from django.http import HttpRequest, HttpResponse, HttpResponseBadRequest
 from django.utils.translation import gettext_lazy as _
 
 from oauth2_provider.models import AbstractApplication
@@ -124,6 +126,49 @@ class ClientProtectedResourceMixin(_ClientProtectedResourceMixin):
         return required_scopes.issubset(allowed_scopes)
 
 
+class BasicAuthMixin:
+    """Mixin for protecting views using HTTP Basic Auth.
+
+    Useful for views being used outside a regular session authenticated, e.g. a
+    CalDAV client providing only username/password authentication.  For its
+    `dispatch` method to be called, the BasicAuthMixin has to be the first
+    parent class. Make sure to call `super().dispatch(self, *args, **kwargs)`
+    if you implement a custom dispatch method.
+    """
+
+    def dispatch(self, request, *args, **kwargs):
+        if request.user is not None and request.user.is_authenticated:
+            return super().dispatch(request, *args, **kwargs)
+
+        auth_header = request.headers.get("Authorization")
+
+        if auth_header is None or not auth_header.startswith("Basic "):
+            return HttpResponse(
+                "Unauthorized",
+                status=401,
+                headers={"WWW-Authenticate": 'Basic realm="AlekSIS", charset="utf-8"'},
+            )
+
+        auth_data = auth_header.removeprefix("Basic ")
+        try:
+            auth_data_decoded = b64decode(auth_data).decode("ascii")
+        except UnicodeDecodeError:
+            try:
+                auth_data_decoded = b64decode(auth_data).decode("utf-8")
+            except UnicodeDecodeError:
+                return HttpResponseBadRequest(
+                    "HTTP Basic Auth credentials must be encoded in UTF-8 or ASCII"
+                )
+        username, password = auth_data_decoded.split(":", 1)
+        user = authenticate(request, username=username, password=password)
+
+        if user is not None:
+            request.user = user
+            return super().dispatch(request, *args, **kwargs)
+        else:
+            return HttpResponse("Unauthorized", status=401)
+
+
 def validate_username_preference_regex(value: str):
     regex = get_site_preferences()["auth__allowed_username_regex"]
     return RegexValidator(regex)(value)
diff --git a/aleksis/core/util/core_helpers.py b/aleksis/core/util/core_helpers.py
index 3a6aa841a4d26823e24c18a34b6e9ca50a4bfff7..7b3921f63af581d071f076f69e4f32ce7d575c23 100644
--- a/aleksis/core/util/core_helpers.py
+++ b/aleksis/core/util/core_helpers.py
@@ -24,7 +24,7 @@ from django.utils.module_loading import import_string
 import django_ical.feedgenerator as feedgenerator
 import recurring_ical_events
 from cache_memoize import cache_memoize
-from icalendar import Calendar, Event, Todo
+from icalendar import Calendar, Component, Event, Timezone, Todo
 
 if TYPE_CHECKING:
     from django.contrib.contenttypes.models import ContentType
@@ -490,37 +490,92 @@ class ExtendedICal20Feed(feedgenerator.ICal20Feed):
     Adds a method to return the actual calendar object.
     """
 
-    def get_calendar_object(self, with_meta: bool = True, with_reference_object: bool = False):
+    def _is_unrequested_prop(
+        self, element: Component, efield: str, params: Optional[dict[str, any]] = None
+    ):
+        """Return True if specific fields are requested and efield is not one of those.
+        If no fields are specified or efield is requested, return False"""
+
+        return (
+            params is not None
+            and element.name in params
+            and efield is not None
+            and efield.upper() not in params[element.name]
+        )
+
+    def get_calendar_object(
+        self,
+        with_meta: bool = True,
+        with_reference_object: bool = False,
+        params: dict[str, any] = None,
+    ):
         cal = Calendar()
-        cal.add("version", "2.0")
-        cal.add("calscale", "GREGORIAN")
-        cal.add("prodid", "-//AlekSIS//AlekSIS//EN")
+        cal_props = {
+            "version": "2.0",
+            "calscale": "GREGORIAN",
+            "prodid": "-//AlekSIS//AlekSIS//EN",
+        }
+        for efield, val in cal_props.items():
+            if self._is_unrequested_prop(cal, efield, params):
+                continue
+
+            cal.add(efield, val)
 
         for ifield, efield in EXTENDED_FEED_FIELD_MAP:
+            if self._is_unrequested_prop(cal, efield, params):
+                continue
+
             val = self.feed.get(ifield)
-            if val is not None:
+            if val:
                 cal.add(efield, val)
 
-        self.write_items(cal, with_meta=with_meta, with_reference_object=with_reference_object)
+        self.write_items(
+            cal, with_meta=with_meta, with_reference_object=with_reference_object, params=params
+        )
+
+        if params is not None and "VTIMEZONE" in params and params["VTIMEZONE"]:
+            cal.add_missing_timezones()
 
         return cal
 
-    def write(self, outfile, encoding):
-        cal = self.get_calendar_object(with_meta=False)
+    def to_ical(self, params: Optional[dict[str, any]] = None):
+        cal = self.get_calendar_object(with_meta=False, params=params)
 
         to_ical = getattr(cal, "as_string", None)
         if not to_ical:
             to_ical = cal.to_ical
-        outfile.write(to_ical())
+        return to_ical()
+
+    def write(self, outfile, params: Optional[dict[str, any]] = None):
+        cal = self.get_calendar_object(with_meta=False, params=params)
+
+        to_ical = getattr(cal, "as_string", None)
+        if not to_ical:
+            to_ical = cal.to_ical
+        outfile.write(self.to_ical())
+
+    def write_items(
+        self,
+        calendar,
+        with_meta: bool = True,
+        with_reference_object: bool = False,
+        params: dict[str, any] = None,
+    ):
+        if params is not None and "timezone" in params:
+            tz = Timezone.from_ical(params["timezone"])
+        else:
+            tz = None
 
-    def write_items(self, calendar, with_meta: bool = True, with_reference_object: bool = False):
         for item in self.items:
             component_type = item.get("component_type")
             element = Todo() if component_type == "todo" else Event()
 
             for ifield, efield in EXTENDED_ITEM_ELEMENT_FIELD_MAP:
+                if self._is_unrequested_prop(element, efield, params):
+                    continue
+
                 val = item.get(ifield)
-                if val is not None:
+                if val:
                     if ifield == "attendee":
                         for list_item in val:
                             element.add(efield, list_item)
@@ -533,10 +588,35 @@ class ExtendedICal20Feed(feedgenerator.ICal20Feed):
                     elif ifield == "reference_object":
                         if with_reference_object:
                             element.add(efield, val, encode=False)
+                    elif (
+                        ifield == "start_datetime"
+                        or ifield == "end_datetime"
+                        or ifield == "timestamp"
+                        or ifield == "created"
+                        or ifield == "updateddate"
+                        or ifield == "rdate"
+                        or ifield == "exdate"
+                    ):
+                        if tz is not None:
+                            val = val.astimezone(tz.to_tz())
+                        element.add(efield, val)
                     else:
                         element.add(efield, val)
             calendar.add_component(element)
 
+        if params is not None and ("expand_start" in params and "expand_end" in params):
+            recurrences = self.get_single_events(
+                start=params["expand_start"],
+                end=params["expand_end"],
+            )
+
+            for event in recurrences:
+                props = list(event.keys())
+                for prop in props:
+                    if self._is_unrequested_prop(event, prop, params):
+                        event.pop(prop)
+                calendar.add_component(event)
+
     def get_single_events(self, start=None, end=None, with_reference_object: bool = False):
         """Get single event objects for this feed."""
         events = recurring_ical_events.of(
diff --git a/aleksis/core/util/dav_handler/__init__.py b/aleksis/core/util/dav_handler/__init__.py
new file mode 100644
index 0000000000000000000000000000000000000000..7a36d7c4bf82132a99de9fd73e365021d06169ee
--- /dev/null
+++ b/aleksis/core/util/dav_handler/__init__.py
@@ -0,0 +1 @@
+from . import calendar, contact, generic  # noqa: F401
diff --git a/aleksis/core/util/dav_handler/base.py b/aleksis/core/util/dav_handler/base.py
new file mode 100644
index 0000000000000000000000000000000000000000..457365fa35d74f953fd7474cfc2fd15399ec4fec
--- /dev/null
+++ b/aleksis/core/util/dav_handler/base.py
@@ -0,0 +1,343 @@
+import warnings
+from typing import Optional
+from xml.etree import ElementTree
+from xml.sax.handler import ContentHandler, feature_namespaces
+from xml.sax.xmlreader import InputSource
+
+from django.core.exceptions import BadRequest
+from django.http import Http404, HttpRequest
+from django.urls import reverse
+
+from defusedxml.sax import make_parser
+
+from ...mixins import DAVResource, RegistryObject
+
+
+class ElementHandler(RegistryObject, is_registry=True):
+    """Abstract class serving as registry for ElementHandlers.
+
+    While parsing an XML document, for every XML element a handler exists for,
+    an instance of the respective sub class is created. By using the `children`
+    attribute, the tree is represented for later processing and creating the
+    response. Using `invisible` causes the Element not to be included in XML.
+    """
+
+    invisible: bool = False
+
+    def __init__(
+        self,
+        request: "DAVRequest",
+        parent: "ElementHandler",
+        attrs,
+        invisible: Optional[bool] = None,
+    ):
+        self.request = request
+        self.parent = parent
+        self.attrs = attrs
+        self.content = ""
+        if invisible is not None:
+            self.invisible = invisible
+        else:
+            self.invisible = self.__class__.invisible
+        self.children = []
+
+    @classmethod
+    def get_name(cls, attrs):
+        return cls.name
+
+    def continueToChild(self, child_class, attrs, invisible: Optional[bool] = None):
+        """Create instance of ElementHandler `child_class` and append to
+        children or return existing child."""
+
+        child = child_class(self.request, self, attrs, invisible=invisible)
+        self.children.append(child)
+        self.request.current_object = child
+
+        return child
+
+    def process(self, stage: str, base: "DAVMultistatus", response: "DAVResponse" = None):
+        """Build elements of the DAV multistatus response."""
+
+        previous_xml = base.current_xml
+
+        if not self.invisible:
+            xml_element = base.current_xml.find(self.name)
+            if xml_element is not None:
+                base.current_xml = xml_element
+
+        stage_method_name = f"process_{stage}"
+        if hasattr(self, stage_method_name) and callable(
+            stage_method := getattr(self, stage_method_name)
+        ):
+            try:
+                stage_method(base, response)
+            except Http404:
+                response.handle_unknown(self.name)
+
+        for child in self.children:
+            child.process(stage, base, response)
+
+        base.current_xml = previous_xml
+
+    def process_xml(self, base: "DAVMultistatus", response: "DAVResponse" = None):
+        """Add XML element representing self to multistatus XML tree."""
+
+        if not self.invisible:
+            base.current_xml = ElementTree.SubElement(base.current_xml, self.name)
+
+    @staticmethod
+    def _get_xml_sub(name):
+        """Convert a tuple like ("DAV:", "prop") to a string like "{DAV:}prop"."""
+
+        return f"{{{name[0]}}}{name[1]}"
+
+
+class DAVRequest(ElementHandler, ContentHandler):
+    """Handler that processes a DAV Request by parsing its XML tree.
+
+    Based on the `resource`, `obj` and `depth`, `resources` and `objects` to be
+    included the response are determined.  Furthermore, `ElementHandler`s may
+    modify those.
+    """
+
+    depth: int | None
+
+    def __init__(
+        self, http_request: HttpRequest, resource: type[DAVResource], obj: Optional[DAVResource]
+    ):
+        super().__init__(self, None, {})
+        self._request = http_request
+
+        if depth := self._request.headers.get("Depth", "infinity"):
+            if depth not in ("0", "1", "infinity"):
+                raise BadRequest("Depth must be 0, 1 or infinity")
+            elif depth == "infinity":
+                self.depth = None
+            else:
+                self.depth = int(depth)
+
+        self.current_object = self
+
+        self.resource = resource
+        self.resources = []
+        self.objects = []
+
+        _is_registry = getattr(self.resource, "_is_registry", False)
+
+        if obj is None:
+            self.resources.append(self.resource)
+
+            if self.depth != 0 and _is_registry:
+                resources = []
+                if self.resource == DAVResource:
+                    resources += self.resource.get_sub_registries().values()
+                if self.resource != DAVResource or self.depth is None:
+                    if hasattr(self.resource, "valid_feeds"):
+                        resources += self.resource.valid_feeds
+                    else:
+                        resources += self.resource.get_registry_objects().values()
+
+                for rcls in resources:
+                    self.resources.append(rcls)
+
+        else:
+            self.objects.append(obj)
+
+    def parse(self):
+        """Start parsing the event-based XML parser."""
+
+        parser = make_parser()
+        parser.setFeature(feature_namespaces, True)
+        parser.setContentHandler(self)
+
+        source = InputSource()
+        source.setByteStream(self._request)
+        return parser.parse(source)
+
+    def startElementNS(self, name, qname, attrs):
+        """Handle start of a new XML element and continue to respective handler.
+
+        `ElementHandler`s may implement a `pre_handle` method to be called at
+        the start of an element.
+        """
+
+        xml_sub = self._get_xml_sub(name)
+        obj = self.get_object_by_name(xml_sub)
+        if obj is not None:
+            self.current_object.continueToChild(obj, attrs.copy())
+            if hasattr(obj, "pre_handle"):
+                self.current_object.pre_handle()
+        else:
+            child = NotImplementedObject(xml_sub)
+            self.current_object.children.append(child)
+            warnings.warn(f"DAVRequest could not parse {xml_sub}.")
+
+    def endElementNS(self, name, qname):
+        """Handle end of an  XML element.
+
+        `ElementHandler`s may implement a `post_handle` method to be called at
+        the end of an element.
+        """
+
+        if self.current_object.name == self._get_xml_sub(name):
+            if hasattr(self.current_object, "post_handle"):
+                self.current_object.post_handle()
+            self.current_object = self.current_object.parent
+
+    def characters(self, content):
+        """Handle content of an XML element."""
+
+        self.current_object.content += content
+
+
+class DAVMultistatus:
+    """Base of a DAV multistatus response.
+
+    Processes children of DAVRequest `request` to build response XML.
+    """
+
+    name = "{DAV:}multistatus"
+
+    def __init__(self, request: DAVRequest):
+        self.request = request
+
+        self.request.resource._register_dav_ns()
+        self.xml_element = ElementTree.Element(self.name)
+        self.current_xml = self.xml_element
+
+    def process(self):
+        """Call process stages of all `children` of a `DAVRequest`.
+
+        There are the following stages, optionally implemented
+        by ElementHandlers using methods named `process_{stage}`:
+        `xml`: Build the response by adding an element to the XML tree.
+        """
+
+        for stage in ("xml",):
+            for child in self.request.children:
+                child.process(stage, self)
+
+
+class NotImplementedObject:
+    """Class to represent requested props that are not implemented."""
+
+    def __init__(self, xml_sub):
+        self.xml_sub = xml_sub
+
+    def process(self, stage, base, response=None):
+        if stage == "xml" and response is not None:
+            response.handle_unknown(self.xml_sub)
+
+
+class NotFoundObject:
+    """Class to represent requested objects that do not exist."""
+
+    def __init__(self, href):
+        self.href = href
+
+
+class DAVResponse:
+    """Part of a `DAVMultistatus` containing props of a single `resource` or `obj`."""
+
+    name = "{DAV:}response"
+
+    def __init__(self, base: "DAVMultistatus", resource, obj):  # noqa: F821
+        self.base = base
+        self.resource = resource
+        self.obj = obj
+
+        self.xml_element = ElementTree.SubElement(self.base.current_xml, self.name)
+
+        self.href = ElementTree.SubElement(self.xml_element, "{DAV:}href")
+
+        if obj is None:
+            if self.resource == DAVResource:
+                self.href.text = reverse("dav_registry")
+            elif getattr(self.resource, "_is_registry", False):
+                self.href.text = reverse("dav_subregistry", args=[self.resource.name])
+            else:
+                self.href.text = reverse(
+                    "dav_resource", args=[self.resource.__bases__[0].name, self.resource.name]
+                )
+        else:
+            if isinstance(obj, NotFoundObject):
+                self.href.text = obj.href
+            else:
+                self.href.text = self.resource.get_dav_absolute_url(
+                    self.obj, self.base.request._request
+                )
+
+        self.propstats = {}
+
+        if isinstance(obj, NotFoundObject):
+            status = ElementTree.SubElement(self.xml_element, "{DAV:}status")
+            status.text = "HTTP/1.1 404 Not Found"
+        else:
+            self.init_propstat(200, "HTTP/1.1 200 OK")
+
+    def init_propstat(self, code, status_text):
+        """Initialize common sub elements of a DAV response."""
+
+        propstat = ElementTree.SubElement(self.xml_element, "{DAV:}propstat")
+        status = ElementTree.SubElement(propstat, "{DAV:}status")
+        status.text = status_text
+
+        self.propstats[code] = propstat
+        return propstat
+
+    def handle_unknown(self, xml_sub):
+        """Handle element for which no `ElementHandler` exists."""
+
+        if 404 not in self.propstats:
+            propstat = self.init_propstat(404, "HTTP/1.1 404 Not Found")
+            ElementTree.SubElement(propstat, "{DAV:}prop")
+
+        propstat = self.propstats[404]
+        prop = propstat.find("{DAV:}prop")
+        ElementTree.SubElement(prop, xml_sub)
+
+
+class QueryBase:
+    """Mixin for REPORT or PROPFIND query `ElementHandler`s.
+
+    Creates `DAVResponse` objects for `resources` and `objects` of a
+    `DAVRequest` to be processed in order to build the response.
+    """
+
+    def process(self, stage: str, base):
+        """Custom process method calling `children`'s process methods for each
+        response."""
+
+        previous_xml = base.current_xml
+
+        stage_method_name = f"process_{stage}"
+        if hasattr(self, stage_method_name) and callable(
+            stage_method := getattr(self, stage_method_name)
+        ):
+            stage_method(base)
+
+        for response in self.responses:
+            if isinstance(response.obj, NotFoundObject):
+                continue
+
+            base.current_xml = response.propstats[200]
+            for child in self.children:
+                child.process(stage, base, response)
+
+        base.current_xml = previous_xml
+
+    def process_xml(self, base):
+        self.responses = []
+        for resource in base.request.resources:
+            response = DAVResponse(base, resource, None)
+            self.responses.append(response)
+
+        for obj in base.request.objects:
+            response = DAVResponse(base, base.request.resource, obj)
+            self.responses.append(response)
+
+    def post_handle(self):
+        if self.request.objects is None:
+            self.request.objects = self.request.resource.get_objects(
+                request=self.request._request, start_qs=self.request.objects
+            )
diff --git a/aleksis/core/util/dav_handler/calendar.py b/aleksis/core/util/dav_handler/calendar.py
new file mode 100644
index 0000000000000000000000000000000000000000..8f3c6220caa08466171c7b0ae5d4640db474c53a
--- /dev/null
+++ b/aleksis/core/util/dav_handler/calendar.py
@@ -0,0 +1,267 @@
+from datetime import datetime
+from xml.etree import ElementTree
+
+from django.db.models import Q
+from django.http import Http404
+from django.urls import reverse
+
+from ..core_helpers import EXTENDED_ITEM_ELEMENT_FIELD_MAP
+from .base import ElementHandler, QueryBase
+from .generic import DAVHref, DAVProp
+
+
+class TimeRangeFilter(ElementHandler):
+    name = "{urn:ietf:params:xml:ns:caldav}time-range"
+    invisible = True
+
+    def post_handle(self):
+        report_base = next(iter(self.request.children))
+
+        for k, v in self.attrs.items():
+            if k in [(None, "start"), (None, "end")]:
+                d = datetime.fromisoformat(v)
+                report_base.get_objects_args[k[1]] = d
+
+
+class TextMatch(ElementHandler):
+    name = "{urn:ietf:params:xml:ns:caldav}text-match"
+    invisible = True
+
+    def _matches(self, obj, field_name):
+        method_name = f"value_{field_name}"
+        if not hasattr(obj, method_name) or not callable(getattr(obj, method_name)):
+            return False
+
+        return self.content.lower() in getattr(obj, method_name)(obj)  # FIXME: Collations
+
+    def post_handle(self):
+        field_name = self.parent._get_field_name(self.parent.attrs.get((None, "name")))
+        objs = [
+            obj.pk
+            for obj in filter(lambda obj: self._matches(obj, field_name), self.request.objects)
+        ]
+        q = Q(pk__in=[objs])
+
+        report_base = next(iter(self.request.children))
+
+        if "additional_filter" in report_base.get_objects_args:
+            q = report_base.get_objects_args["additional_filter"] & q
+
+        report_base.get_objects_args["additional_filter"] = q
+
+
+class PropFilter(ElementHandler):
+    name = "{urn:ietf:params:xml:ns:caldav}prop-filter"
+    invisible = True
+
+    @staticmethod
+    def _get_field_name(ical_name):
+        fields = list(filter(lambda f: f[1] == ical_name.lower(), EXTENDED_ITEM_ELEMENT_FIELD_MAP))
+        try:
+            return fields.pop()[0]
+        except StopIteration:
+            return None
+
+
+class CalDAVProp(ElementHandler):
+    name = "{urn:ietf:params:xml:ns:caldav}prop"
+    invisible = True
+
+    @classmethod
+    def get_name(cls, attrs):
+        name = attrs.get((None, "name"))
+        return f"{cls.name}-{name}"
+
+    def _get_calendar_data(self, parent):
+        """Helper method to find the base `CalendarData` instance."""
+
+        if isinstance(parent, CalendarData):
+            return parent
+        return self._get_calendar_data(parent.parent)
+
+    def pre_handle(self):
+        calendar_data = self._get_calendar_data(self.parent.parent)
+        comp_name = self.parent.attrs.get((None, "name"))
+        prop = self.attrs.get((None, "name"))
+        calendar_data.params.setdefault(comp_name, []).append(prop)
+
+
+class CalDAVFilter(ElementHandler):
+    name = "{urn:ietf:params:xml:ns:caldav}filter"
+    invisible = True
+
+    def pre_handle(self):
+        self.filters = {}
+
+
+class CalDAVCompFilter(ElementHandler):
+    name = "{urn:ietf:params:xml:ns:caldav}comp-filter"
+    invisible = True
+
+
+class CalDAVComp(ElementHandler):
+    name = "{urn:ietf:params:xml:ns:caldav}comp"
+    invisible = True
+
+    @classmethod
+    def get_name(cls, attrs):
+        name = attrs.get((None, "name"))
+        return f"{cls.name}-{name}"
+
+    def pre_handle(self):
+        if self.attrs.get((None, "name")) == "VTIMEZONE":
+            self.parent.parent.params["VTIMEZONE"] = True
+
+
+class Expand(ElementHandler):
+    name = "{urn:ietf:params:xml:ns:caldav}expand"
+
+    def post_handle(self):
+        for k, v in self.attrs.items():
+            if k in [(None, "start"), (None, "end")]:
+                d = datetime.fromisoformat(v)
+                self.parent.params[f"expand_{k[1]}"] = d
+
+
+class CalendarData(ElementHandler):
+    name = "{urn:ietf:params:xml:ns:caldav}calendar-data"
+
+    def pre_handle(self):
+        self.params = {}
+
+    def post_handle(self):
+        if not self.params:
+            attrs = {(None, "name"): "VCALENDAR"}
+            vcalendar = CalDAVComp(self.request, self, attrs)
+            self.children.append(vcalendar)
+
+            self.params["VTIMEZONE"] = True
+
+            for comp_name in ("VTIMEZONE", "VEVENT"):
+                attrs = {(None, "name"): comp_name}
+                comp = CalDAVComp(self.request, self, attrs)
+                vcalendar.children.append(comp)
+
+    def process_xml(self, base, response):
+        super().process_xml(base, response)
+        if not self.invisible:
+            if response.obj is not None:
+                objects = response.resource.objects.filter(pk=response.obj.pk)
+            else:
+                objects = []
+
+            ical = response.resource.get_dav_file_content(
+                base.request._request, objects, params=self.params
+            )
+            base.current_xml.text = ical.decode()
+
+
+class CalendarColor(ElementHandler):
+    name = "{http://apple.com/ns/ical/}calendar-color"
+
+    def process_xml(self, base, response):
+        if not hasattr(response.resource, "get_color"):
+            raise Http404
+
+        super().process_xml(base, response)
+        base.current_xml.text = response.resource.get_color()
+
+
+class CalendarDescription(ElementHandler):
+    name = "{urn:ietf:params:xml:ns:caldav}calendar-description"
+
+    def process_xml(self, base, response):
+        if not hasattr(response.resource, "get_description"):
+            raise Http404
+
+        super().process_xml(base, response)
+        base.current_xml.text = response.resource.get_description()
+
+
+class CalendarHomeSet(ElementHandler):
+    name = "{urn:ietf:params:xml:ns:caldav}calendar-home-set"
+
+    def process_xml(self, base, response):
+        super().process_xml(base, response)
+        href = ElementTree.SubElement(base.current_xml, "{DAV:}href")
+        href.text = reverse("dav_subregistry", args=["calendar"])
+
+
+class SupportedCalendarComponentSet(ElementHandler):
+    name = "{urn:ietf:params:xml:ns:caldav}supported-calendar-component-set"
+
+    def process_xml(self, base, response):
+        super().process_xml(base, response)
+        comp = ElementTree.SubElement(base.current_xml, "{urn:ietf:params:xml:ns:caldav}comp")
+        comp.set("name", "VEVENT")
+
+
+class SupportedCollationSet(ElementHandler):
+    name = "{urn:ietf:params:xml:ns:caldav}supported-collation-set"
+
+    def process_xml(self, base, response):
+        super().process_xml(base, response)
+
+        supported_collations = [
+            "i;ascii-casemap",
+            "i;octet",
+        ]
+
+        for collation in supported_collations:
+            supported_collation = ElementTree.SubElement(
+                base.current_xml, "{urn:ietf:params:xml:ns:caldav}supported-collation"
+            )
+            supported_collation.text = collation
+
+
+class Timezone(ElementHandler):
+    name = "{urn:ietf:params:xml:ns:caldav}timezone"
+    invisible = True
+
+
+class ReportBase(QueryBase):
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+        self.get_objects_args = {
+            "request": self.request._request,
+        }
+
+    def post_handle(self):
+        super().post_handle()
+
+        self.request.objects = self.request.resource.get_objects(**self.get_objects_args)
+
+        try:
+            timezone = next(filter(lambda child: isinstance(child, Timezone), self.children))
+        except StopIteration:
+            timezone = None
+
+        if timezone is not None:
+            prop = next(filter(lambda child: isinstance(child, DAVProp), self.children))
+            calendar_data = next(
+                filter(lambda child: isinstance(child, CalendarData), prop.children)
+            )
+            calendar_data.params["timezone"] = timezone.content
+
+
+class CalendarQuery(ReportBase, ElementHandler):
+    name = "{urn:ietf:params:xml:ns:caldav}calendar-query"
+
+    def pre_handle(self):
+        self.request.resources = []
+
+
+class CalendarMultiget(ReportBase, ElementHandler):
+    name = "{urn:ietf:params:xml:ns:caldav}calendar-multiget"
+
+    def pre_handle(self):
+        self.request.resources = []
+        self.request.objects = []
+
+    def post_handle(self):
+        super().post_handle()
+
+        for child in self.children:
+            if child.name == DAVHref.name:
+                child.invisible = True
diff --git a/aleksis/core/util/dav_handler/contact.py b/aleksis/core/util/dav_handler/contact.py
new file mode 100644
index 0000000000000000000000000000000000000000..f6f7d84e12ef960a60a4436692aaca4e0e2cb623
--- /dev/null
+++ b/aleksis/core/util/dav_handler/contact.py
@@ -0,0 +1,99 @@
+from xml.etree import ElementTree
+
+from django.http import Http404
+from django.urls import reverse
+
+from .base import ElementHandler, QueryBase
+from .generic import DAVHref
+
+
+class CardDAVProp(ElementHandler):
+    name = "{urn:ietf:params:xml:ns:carddav}prop"
+    invisible = True
+
+    @classmethod
+    def get_name(cls, attrs):
+        name = attrs.get((None, "name"))
+        return f"{cls.name}-{name}"
+
+    def _get_address_data(self, parent):
+        """Helper method to find the base `AddressData` instance."""
+
+        if isinstance(parent, AddressData):
+            return parent
+        return self._get_address_data(parent.parent)
+
+    def pre_handle(self):
+        address_data = self._get_address_data(self.parent)
+        comp_name = "VCARD"
+        prop = self.attrs.get((None, "name"))
+        address_data.params.setdefault(comp_name, []).append(prop)
+
+
+class AddressData(ElementHandler):
+    name = "{urn:ietf:params:xml:ns:carddav}address-data"
+
+    def pre_handle(self):
+        self.params = {}
+
+    def process_xml(self, base, response):
+        super().process_xml(base, response)
+        if not self.invisible:
+            objects = [response.obj] if response.obj is not None else []
+            vcf = response.resource.get_dav_file_content(
+                base.request._request, objects, params=self.params
+            )
+            base.current_xml.text = vcf.decode()
+
+
+class AddressbookDescription(ElementHandler):
+    name = "{urn:ietf:params:xml:ns:carddav}addressbook-description"
+
+    def process_xml(self, base, response):
+        if not hasattr(response.resource, "get_description"):
+            raise Http404
+
+        response.resource.get_description()
+        base.current_xml.text = response.resource.get_description()
+        super().process_xml(base, response)
+
+
+class AddressbookHomeSet(ElementHandler):
+    name = "{urn:ietf:params:xml:ns:carddav}addressbook-home-set"
+
+    def process_xml(self, base, response):
+        super().process_xml(base, response)
+        href = ElementTree.SubElement(base.current_xml, "{DAV:}href")
+        href.text = reverse("dav_subregistry", args=["contact"])
+
+
+class SupportedAddressData(ElementHandler):
+    name = "{urn:ietf:params:xml:ns:carddav}supported-address-data"
+
+    def process_xml(self, base, response):
+        super().process_xml(base, response)
+        comp = ElementTree.SubElement(
+            base.current_xml, "{urn:ietf:params:xml:ns:carddav}address-data-type"
+        )
+        comp.set("content-type", "text/vcard")
+        comp.set("version", "4.0")
+
+
+class AddressbookQuery(QueryBase, ElementHandler):
+    name = "{urn:ietf:params:xml:ns:carddav}addressbook-query"
+
+    def pre_handle(self):
+        self.request.resources = []
+
+
+class AddressbookMultiget(QueryBase, ElementHandler):
+    name = "{urn:ietf:params:xml:ns:carddav}addressbook-multiget"
+
+    def pre_handle(self):
+        self.request.resources = []
+        self.request.objects = []
+
+    def post_handle(self):
+        for child in self.children:
+            if child.name == DAVHref.name:
+                child.invisible = True
diff --git a/aleksis/core/util/dav_handler/generic.py b/aleksis/core/util/dav_handler/generic.py
new file mode 100644
index 0000000000000000000000000000000000000000..188d50d73c5692dfaca5dfba20307b832684ad1f
--- /dev/null
+++ b/aleksis/core/util/dav_handler/generic.py
@@ -0,0 +1,184 @@
+from xml.etree import ElementTree
+
+from django.http import Http404
+from django.urls import resolve
+
+from ...mixins import DAVResource
+from .base import DAVMultistatus, DAVResponse, ElementHandler, NotFoundObject, QueryBase
+
+
+class DAVEtag(ElementHandler):
+    name = "{DAV:}getetag"
+
+    def process_xml(self, base, response):
+        super().process_xml(base, response)
+        objects = [response.obj] if response.obj is not None else []
+        etag = response.resource.getetag(base.request._request, objects)
+        base.current_xml.text = f'"{etag}"'
+
+
+class DAVDisplayname(ElementHandler):
+    name = "{DAV:}displayname"
+
+    def process_xml(self, base, response):
+        super().process_xml(base, response)
+        base.current_xml.text = response.resource.get_dav_verbose_name()
+
+
+class DAVResourcetype(ElementHandler):
+    name = "{DAV:}resourcetype"
+
+    def process_xml(self, base, response):
+        super().process_xml(base, response)
+        resource_types = ["{DAV:}collection"]
+        if not getattr(response.resource, "_is_registry", False):
+            resource_types += response.resource.dav_resource_types
+
+        if response.obj is None:
+            for res_type_attr in resource_types:
+                ElementTree.SubElement(base.current_xml, res_type_attr)
+
+
+class DAVCurrentUserPrincipal(ElementHandler):
+    name = "{DAV:}current-user-principal"
+
+    def process_xml(self, base, response):
+        super().process_xml(base, response)
+        href = ElementTree.SubElement(base.current_xml, "{DAV:}href")
+        person_pk = base.request._request.user.person.pk
+        href.text = f"/dav/contact/person/{person_pk}.vcf"  # FIXME: Implement principals
+
+
+class DAVGetcontenttype(ElementHandler):
+    name = "{DAV:}getcontenttype"
+
+    def process_xml(self, base, response):
+        resource = response.resource
+        if response.obj is not None:
+            resource = response.obj
+        content_type = resource.get_dav_content_type()
+
+        if not content_type:
+            raise Http404
+
+        super().process_xml(base, response)
+        base.current_xml.text = content_type
+
+
+class DAVGetcontentlength(ElementHandler):
+    name = "{DAV:}getcontentlength"
+
+    def process_xml(self, base, response):
+        obj = response.obj
+        if obj is None:
+            raise Http404
+
+        super().process_xml(base, response)
+        base.current_xml.text = str(
+            len(response.resource.get_dav_file_content(base.request._request, [obj]))
+        )
+
+
+class DAVSupportedReportSet(ElementHandler):
+    name = "{DAV:}supported-report-set"
+
+    def process_xml(self, base, response):
+        super().process_xml(base, response)
+
+        supported_reports = [
+            ("urn:ietf:params:xml:ns:caldav", "calendar-multiget"),
+            ("urn:ietf:params:xml:ns:caldav", "calendar-query"),
+        ]
+
+        for r in supported_reports:
+            supported_report = ElementTree.SubElement(base.current_xml, "{DAV:}supported-report")
+            report = ElementTree.SubElement(supported_report, "{DAV:}report")
+            ElementTree.SubElement(report, self._get_xml_sub(r))
+
+
+class DAVCurrentUserPrivilegeSet(ElementHandler):
+    name = "{DAV:}current-user-privilege-set"
+
+    def process_xml(self, base, response):
+        super().process_xml(base, response)
+
+        privileges = [
+            ("DAV:", "read"),
+        ]
+
+        for p in privileges:
+            privilege = ElementTree.SubElement(base.current_xml, "{DAV:}privilege")
+            ElementTree.SubElement(privilege, self._get_xml_sub(p))
+
+
+class DAVHref(ElementHandler):
+    name = "{DAV:}href"
+
+    def post_handle(self):
+        res = resolve(self.content)
+        name = res.kwargs.get("name")
+        pk = res.kwargs.get("id")
+
+        resource = DAVResource.registered_objects_dict[name]
+        try:
+            obj = resource.get_objects(self.request._request).get(pk=pk)
+        except resource.DoesNotExist:
+            obj = NotFoundObject(self.content)
+
+        self.request.objects = list(self.request.objects)
+        self.request.objects.append(obj)
+
+
+class DAVProp(ElementHandler):
+    name = "{DAV:}prop"
+
+
+class DAVPropname(ElementHandler):
+    name = "{DAV:}propname"
+    invisible = True
+
+    def process_xml(self, base: DAVMultistatus, response: DAVResponse = None):
+        base.current_xml = ElementTree.SubElement(base.current_xml, DAVProp.name)
+
+        response.resource._add_dav_propnames(base.current_xml)
+
+
+class DAVAllprop(ElementHandler):
+    name = "{DAV:}allprop"
+    invisible = True
+
+    def pre_handle(self):
+        for name in self.request.resource.dav_live_props:
+            self.request.startElementNS(name, None, {})
+            self.request.endElementNS(name, None)
+
+    def process(self, stage: str, base: DAVMultistatus, response: DAVResponse = None):
+        xml_element = base.current_xml.find(DAVProp.name)
+        if xml_element is not None:
+            base.current_xml = xml_element
+
+        super().process(stage, base, response)
+
+    def process_xml(self, base: DAVMultistatus, response: DAVResponse = None):
+        base.current_xml = ElementTree.SubElement(base.current_xml, DAVProp.name)
+
+
+class Propfind(QueryBase, ElementHandler):
+    name = "{DAV:}propfind"
+    invisible = True
+
+    def post_handle(self):
+        super().post_handle()
+
+        _is_registry = getattr(self.request.resource, "_is_registry", False)
+
+        if not _is_registry or self.request.depth is None:
+            for resource in filter(
+                lambda r: not getattr(r, "_is_registry", False), self.request.resources
+            ):
+                try:
+                    objs = resource.get_objects(self.request._request)
+                except NotImplementedError:
+                    objs = []
+
+                self.request.objects += objs
diff --git a/aleksis/core/views.py b/aleksis/core/views.py
index a356ad0df0ded55ee7bdb967ef5953bd054d8897..1d5397d82547d22292ffb8cb59c906a0766c5db7 100644
--- a/aleksis/core/views.py
+++ b/aleksis/core/views.py
@@ -1,6 +1,8 @@
 from textwrap import wrap
-from typing import Any, Optional
+from typing import Any, ClassVar, Optional
 from urllib.parse import urlencode, urlparse, urlunparse
+from xml.etree import ElementTree
+from xml.sax import SAXParseException
 
 from django.apps import apps
 from django.conf import settings
@@ -30,6 +32,7 @@ from django.utils.decorators import method_decorator
 from django.utils.translation import get_language
 from django.utils.translation import gettext_lazy as _
 from django.views.decorators.cache import never_cache
+from django.views.decorators.csrf import csrf_exempt
 from django.views.defaults import ERROR_500_TEMPLATE_NAME
 from django.views.generic.base import TemplateView, View
 from django.views.generic.detail import DetailView, SingleObjectMixin
@@ -97,7 +100,9 @@ from .mixins import (
     AdvancedDeleteView,
     AdvancedEditView,
     CalendarEventMixin,
+    DAVResource,
     ObjectAuthenticator,
+    RegistryObject,
     SuccessNextMixin,
 )
 from .models import (
@@ -124,6 +129,7 @@ from .tables import (
     UserObjectPermissionTable,
 )
 from .util import messages
+from .util.auth_helpers import BasicAuthMixin
 from .util.celery_progress import render_progress_page
 from .util.core_helpers import (
     generate_random_code,
@@ -133,6 +139,7 @@ from .util.core_helpers import (
     has_person,
     objectgetter_optional,
 )
+from .util.dav_handler.base import DAVMultistatus, DAVRequest
 from .util.forms import PreferenceLayout
 from .util.pdf import render_pdf
 
@@ -1374,19 +1381,44 @@ class ObjectRepresentationView(View):
         raise PermissionDenied()
 
 
-class ICalFeedView(PermissionRequiredMixin, View):
+class RegistryObjectViewMixin:
+    """Provide single registry object by its name."""
+
+    registry_object: ClassVar[type[RegistryObject] | RegistryObject] = None
+
+    def get_object(self) -> type[RegistryObject]:
+        if not self.registry_object:
+            raise NotImplementedError("There is no registry object set.")
+
+        if "name" in self.kwargs and "subregistry" not in self.kwargs:
+            return self._get_sub_registry()
+
+        name = self.kwargs.get("name")
+        if name is None:
+            return self.registry_object
+        if name in self.registry_object.registered_objects_dict:
+            return self.registry_object.registered_objects_dict[name]
+        raise Http404
+
+    def _get_sub_registry(self) -> type[RegistryObject]:
+        name = self.kwargs.get("name")
+        if name in self.registry_object.get_sub_registries():
+            return self.registry_object.get_sub_registry_by_name(name)
+        raise Http404
+
+
+class ICalFeedView(RegistryObjectViewMixin, PermissionRequiredMixin, View):
     """View to generate an iCal feed for a calendar."""
 
     permission_required = "core.view_calendar_feed_rule"
+    registry_object = CalendarEventMixin
 
     def get(self, request, name, *args, **kwargs):
-        if name in CalendarEventMixin.registered_objects_dict:
-            calendar = CalendarEventMixin.registered_objects_dict[name]
-            feed = calendar.create_feed(request)
-            response = HttpResponse(content_type="text/calendar")
-            feed.write(response, "utf-8")
-            return response
-        raise Http404
+        calendar = self.get_object()
+        feed = calendar.create_feed(request)
+        response = HttpResponse(content_type="text/calendar")
+        feed.write(response)
+        return response
 
 
 class ICalAllFeedsView(PermissionRequiredMixin, View):
@@ -1398,7 +1430,90 @@ class ICalAllFeedsView(PermissionRequiredMixin, View):
         response = HttpResponse(content_type="text/calendar")
         for calendar in CalendarEventMixin.get_enabled_feeds(request):
             feed = calendar.create_feed(request)
-            feed.write(response, "utf-8")
+            feed.write(response)
+        return response
+
+
+@method_decorator(csrf_exempt, name="dispatch")
+class DAVResourceView(BasicAuthMixin, PermissionRequiredMixin, RegistryObjectViewMixin, View):
+    """View for CalDAV collections."""
+
+    registry_object = DAVResource
+    permission_required = "core.view_calendar_feed_rule"
+
+    http_method_names = View.http_method_names + ["propfind", "report"]
+    dav_compliance = []
+
+    _dav_request: ClassVar[DAVRequest]
+
+    def dispatch(self, request, *args, **kwargs):
+        res = super().dispatch(request, *args, **kwargs)
+
+        res.headers["DAV"] = ", ".join(
+            ["1", "3", "calendar-access", "addressbook"] + self.dav_compliance
+        )
+
+        return res
+
+    def options(self, request, *args, **kwargs):
+        res = super().options(request, *args, **kwargs)
+        return res
+
+    def propfind(self, request, *args, **kwargs):
+        resource = self.get_object()
+        self._dav_request = DAVRequest(request, resource, None)
+
+        try:
+            self._dav_request.parse()
+        except SAXParseException:
+            return HttpResponseBadRequest()
+
+        multistatus = DAVMultistatus(self._dav_request)
+        multistatus.process()
+
+        response = HttpResponse(
+            ElementTree.tostring(multistatus.xml_element), content_type="text/xml", status=207
+        )
+        return response
+
+    def report(self, request, *args, **kwargs):
+        return self.propfind(request, *args, **kwargs)
+
+
+class DAVSingleResourceView(DAVResourceView):
+    """View for single CalDAV resources."""
+
+    def propfind(self, request, id, *args, **kwargs):  # noqa: A002
+        resource = self.get_object()
+        try:
+            objects = resource.get_objects(request).get(pk=id)
+        except resource.DoesNotExist as exc:
+            raise Http404 from exc
+
+        self._dav_request = DAVRequest(request, resource, objects)
+
+        try:
+            self._dav_request.parse()
+        except SAXParseException:
+            return HttpResponseBadRequest()
+
+        multistatus = DAVMultistatus(self._dav_request)
+        multistatus.process()
+
+        response = HttpResponse(
+            ElementTree.tostring(multistatus.xml_element), content_type="text/xml", status=207
+        )
+        return response
+
+    def get(self, request, name, id, *args, **kwargs):  # noqa: A002
+        resource: DAVResource = self.get_object()
+        try:
+            obj = resource.get_objects(request).get(pk=id)
+        except resource.DoesNotExist as exc:
+            raise Http404 from exc
+
+        response = HttpResponse(content_type=resource.get_dav_content_type())
+        response.write(resource.get_dav_file_content(request, objects=[obj]))
         return response
 
 
@@ -1409,3 +1524,17 @@ class CustomLogoutView(LogoutView):
 
     def get(self, *args, **kwargs):
         return self.post(*args, **kwargs)
+
+
+@method_decorator(csrf_exempt, name="dispatch")
+class DAVWellKnownView(View):
+    http_method_names = View.http_method_names + ["propfind", "report"]
+
+    def get(self, request, *args, **kwargs):
+        return redirect("dav_registry")
+
+    def propfind(self, request, *args, **kwargs):
+        return redirect("dav_registry")
+
+    def post(self, request, *args, **kwargs):
+        return redirect("dav_registry")
diff --git a/pyproject.toml b/pyproject.toml
index 64e7ef4f0d7ac85b90ea5d16674de3f63088ff83..48b4290e640c69a69a9d61cf7d52b7f3af7370d8 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -133,6 +133,7 @@ tqdm = "^4.66.1"
 django-pg-rrule = "^0.3.1"
 libsass = "^0.23.0"
 graphene-django-optimizer-reloaded = "^0.9.2"
+defusedxml = "^0.7.1"
 
 [tool.poetry.extras]
 ldap = ["django-auth-ldap"]