From d1375e5f7b5d7543ec694df7c2f47b0a967f8951 Mon Sep 17 00:00:00 2001
From: Thomas Hackl <hackl@data-quest.de>
Date: Mon, 25 Nov 2024 08:41:07 +0000
Subject: [PATCH] =?UTF-8?q?Resolve=20"Garuda=20in=20den=20Kern=20=C3=BCber?=
 =?UTF-8?q?nehmen"?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit

Closes #3326

Merge request studip/studip!3035
---
 app/controllers/admin/courses.php             |  23 +
 app/controllers/massmail/message.php          | 394 ++++++++++++++++++
 app/controllers/massmail/overview.php         |  32 ++
 app/controllers/massmail/permissions.php      | 174 ++++++++
 app/controllers/massmail/quick.php            |  90 ++++
 app/controllers/massmail/settings.php         | 109 +++++
 app/views/admin/courses/massmail.php          |  10 +
 .../6.0.32_integrate_garuda_plugin.php        | 285 +++++++++++++
 .../ConditionalAdmission.php                  |   2 +
 .../PreferentialAdmission.php                 |   1 +
 lib/classes/JsonApi/RouteMap.php              |   9 +
 .../JsonApi/Routes/MassMail/Authority.php     |  30 ++
 .../Routes/MassMail/MassMailMessagesIndex.php |  71 ++++
 .../MassMail/MassMailPermissionsIndex.php     |  31 ++
 .../MassMail/MassMailPermissionsShow.php      |  32 ++
 .../JsonApi/Routes/UserFilters/Authority.php  |  11 +-
 .../UserFilters/UserFilterFieldsIndex.php     |  38 +-
 .../Routes/UserFilters/UserFiltersCreate.php  |   8 +-
 .../Routes/UserFilters/UserFiltersDelete.php  |  12 +-
 .../Routes/UserFilters/UserFiltersUpdate.php  |  14 +-
 lib/classes/JsonApi/SchemaMap.php             |   6 +-
 lib/classes/JsonApi/Schemas/Degree.php        |  78 ++++
 .../JsonApi/Schemas/MassMailMessage.php       |  84 ++++
 .../JsonApi/Schemas/MassMailPermission.php    | 121 ++++++
 lib/classes/{admission => }/UserFilter.php    | 110 +++--
 .../{admission => }/UserFilterField.php       | 105 +++--
 .../DatafieldCondition.php                    |  32 +-
 .../DegreeCondition.php                       |   7 +-
 .../UserFilterFields/DomainCondition.php      |  45 ++
 .../MassMail/MassMailDegreeFilter.php         | 126 ++++++
 .../MassMail/MassMailDomainFilter.php         |  75 ++++
 .../MassMail/MassMailGenderFilter.php         |  74 ++++
 .../MassMail/MassMailInstituteFilter.php      | 140 +++++++
 .../MassMail/MassMailPermissionFilter.php     | 111 +++++
 .../MassMailSelfAssignedInstituteFilter.php   | 137 ++++++
 .../MassMailSemesterOfStudyFilter.php         |  75 ++++
 .../MassMail/MassMailStatusgroupFilter.php    |  88 ++++
 .../MassMail/MassMailSubjectFilter.php        | 125 ++++++
 .../PermissionCondition.php                   |   7 +-
 .../SemesterOfStudyCondition.php              |  11 +-
 .../StgteilVersionCondition.php               |  17 +-
 .../SubjectCondition.php                      |   7 +-
 .../SubjectConditionAny.php                   |   6 +-
 lib/classes/UserFilterRange.php               |  29 ++
 lib/classes/admission/CourseSet.php           |  46 +-
 lib/classes/forms/CheckboxCollectionInput.php |  25 ++
 lib/classes/forms/Fieldset.php                |  17 +
 lib/classes/forms/FileInput.php               |  23 +
 lib/classes/forms/Form.php                    |  20 +-
 lib/classes/forms/QuicksearchListInput.php    |  19 +
 lib/classes/forms/SerialWysiwygInput.php      |  34 ++
 lib/classes/forms/UserFilterInput.php         |  60 +++
 lib/cronjobs/send_massmails.php               | 107 +++++
 lib/models/MassMail/MassMailFilter.php        |  34 ++
 lib/models/MassMail/MassMailMarker.php        | 181 ++++++++
 lib/models/MassMail/MassMailMessage.php       | 373 +++++++++++++++++
 lib/models/MassMail/MassMailPermission.php    | 139 ++++++
 lib/models/MassMail/MassMailToken.php         |  25 ++
 lib/navigation/MessagingNavigation.php        |  31 +-
 resources/vue/base-components.js              |   4 +
 resources/vue/components/StudipUserFilter.vue |  20 +-
 resources/vue/components/StudipWysiwyg.vue    |   2 +
 .../vue/components/form_inputs/FileUpload.vue | 198 +++++++++
 .../form_inputs/QuicksearchListInput.vue      |  97 +++++
 .../form_inputs/SerialTextMarkers.vue         |  80 ++++
 .../form_inputs/UserFilterInput.vue           | 146 +++++++
 .../massmail/MassMailMessagesList.vue         | 153 +++++++
 .../massmail/MassMailPermissions.vue          | 108 +++++
 templates/forms/checkbox_collection_input.php |  33 ++
 templates/forms/fieldset.php                  |  10 +-
 templates/forms/file_input.php                |  11 +
 templates/forms/form.php                      |   1 +
 templates/forms/quicksearchlist_input.php     |  18 +
 templates/forms/radio_input.php               |  16 +-
 templates/forms/serial_wysiwyg_input.php      |  17 +
 templates/forms/textarea_input.php            |  28 +-
 templates/forms/user_filter_input.php         |  19 +
 77 files changed, 4928 insertions(+), 159 deletions(-)
 create mode 100644 app/controllers/massmail/message.php
 create mode 100644 app/controllers/massmail/overview.php
 create mode 100644 app/controllers/massmail/permissions.php
 create mode 100644 app/controllers/massmail/quick.php
 create mode 100644 app/controllers/massmail/settings.php
 create mode 100644 app/views/admin/courses/massmail.php
 create mode 100644 db/migrations/6.0.32_integrate_garuda_plugin.php
 create mode 100644 lib/classes/JsonApi/Routes/MassMail/Authority.php
 create mode 100644 lib/classes/JsonApi/Routes/MassMail/MassMailMessagesIndex.php
 create mode 100644 lib/classes/JsonApi/Routes/MassMail/MassMailPermissionsIndex.php
 create mode 100644 lib/classes/JsonApi/Routes/MassMail/MassMailPermissionsShow.php
 create mode 100644 lib/classes/JsonApi/Schemas/Degree.php
 create mode 100644 lib/classes/JsonApi/Schemas/MassMailMessage.php
 create mode 100644 lib/classes/JsonApi/Schemas/MassMailPermission.php
 rename lib/classes/{admission => }/UserFilter.php (71%)
 rename lib/classes/{admission => }/UserFilterField.php (79%)
 rename lib/classes/{admission/userfilter => UserFilterFields}/DatafieldCondition.php (81%)
 rename lib/classes/{admission/userfilter => UserFilterFields}/DegreeCondition.php (92%)
 create mode 100644 lib/classes/UserFilterFields/DomainCondition.php
 create mode 100644 lib/classes/UserFilterFields/MassMail/MassMailDegreeFilter.php
 create mode 100644 lib/classes/UserFilterFields/MassMail/MassMailDomainFilter.php
 create mode 100644 lib/classes/UserFilterFields/MassMail/MassMailGenderFilter.php
 create mode 100644 lib/classes/UserFilterFields/MassMail/MassMailInstituteFilter.php
 create mode 100644 lib/classes/UserFilterFields/MassMail/MassMailPermissionFilter.php
 create mode 100644 lib/classes/UserFilterFields/MassMail/MassMailSelfAssignedInstituteFilter.php
 create mode 100644 lib/classes/UserFilterFields/MassMail/MassMailSemesterOfStudyFilter.php
 create mode 100644 lib/classes/UserFilterFields/MassMail/MassMailStatusgroupFilter.php
 create mode 100644 lib/classes/UserFilterFields/MassMail/MassMailSubjectFilter.php
 rename lib/classes/{admission/userfilter => UserFilterFields}/PermissionCondition.php (90%)
 rename lib/classes/{admission/userfilter => UserFilterFields}/SemesterOfStudyCondition.php (89%)
 rename lib/classes/{admission/userfilter => UserFilterFields}/StgteilVersionCondition.php (84%)
 rename lib/classes/{admission/userfilter => UserFilterFields}/SubjectCondition.php (92%)
 rename lib/classes/{admission/userfilter => UserFilterFields}/SubjectConditionAny.php (88%)
 create mode 100644 lib/classes/UserFilterRange.php
 create mode 100644 lib/classes/forms/CheckboxCollectionInput.php
 create mode 100644 lib/classes/forms/FileInput.php
 create mode 100644 lib/classes/forms/QuicksearchListInput.php
 create mode 100644 lib/classes/forms/SerialWysiwygInput.php
 create mode 100644 lib/classes/forms/UserFilterInput.php
 create mode 100644 lib/cronjobs/send_massmails.php
 create mode 100644 lib/models/MassMail/MassMailFilter.php
 create mode 100644 lib/models/MassMail/MassMailMarker.php
 create mode 100644 lib/models/MassMail/MassMailMessage.php
 create mode 100644 lib/models/MassMail/MassMailPermission.php
 create mode 100644 lib/models/MassMail/MassMailToken.php
 create mode 100644 resources/vue/components/form_inputs/FileUpload.vue
 create mode 100644 resources/vue/components/form_inputs/QuicksearchListInput.vue
 create mode 100644 resources/vue/components/form_inputs/SerialTextMarkers.vue
 create mode 100644 resources/vue/components/form_inputs/UserFilterInput.vue
 create mode 100644 resources/vue/components/massmail/MassMailMessagesList.vue
 create mode 100644 resources/vue/components/massmail/MassMailPermissions.vue
 create mode 100644 templates/forms/checkbox_collection_input.php
 create mode 100644 templates/forms/file_input.php
 create mode 100644 templates/forms/quicksearchlist_input.php
 create mode 100644 templates/forms/serial_wysiwyg_input.php
 create mode 100644 templates/forms/user_filter_input.php

diff --git a/app/controllers/admin/courses.php b/app/controllers/admin/courses.php
index 3d63cd9f3e0..890a9ab0b90 100644
--- a/app/controllers/admin/courses.php
+++ b/app/controllers/admin/courses.php
@@ -487,6 +487,16 @@ class Admin_CoursesController extends AuthenticatedController
                         'data-dialog' => 'size=big'
                     ]);
                 break;
+            case 23: // Mass mail to selected courses
+                $data['buttons_top'] = '<label>' . _('Alle auswählen') .
+                    '<input type="checkbox" data-proxyfor=".course-admin td:last-child :checkbox"></label>';
+                $data['buttons_bottom'] = (string) \Studip\Button::createAccept(
+                    _('Nachricht an ausgewählte Veranstaltungen'), 'massmail',
+                    [
+                        'formaction' => URLHelper::getURL('dispatch.php/massmail/quick/courses'),
+                        'data-dialog' => 'size=big'
+                    ]);
+                break;
             default:
                 foreach (PluginManager::getInstance()->getPlugins(AdminCourseAction::class) as $plugin) {
                     if ($GLOBALS['user']->cfg->MY_COURSES_ACTION_AREA === get_class($plugin)) {
@@ -837,6 +847,11 @@ class Admin_CoursesController extends AuthenticatedController
                 $template->course = $course;
                 $d['action'] = $template->render();
                 break;
+            case 23: //Masssenexport Teilnehmendendaten
+                $template = $tf->open('admin/courses/massmail');
+                $template->course = $course;
+                $d['action'] = $template->render();
+                break;
             default:
                 foreach (PluginManager::getInstance()->getPlugins(AdminCourseAction::class) as $plugin) {
                     if ($GLOBALS['user']->cfg->MY_COURSES_ACTION_AREA === get_class($plugin)) {
@@ -1435,6 +1450,14 @@ class Admin_CoursesController extends AuthenticatedController
                 'partial'    => 'batch_export_members.php'
 
             ],
+            23 => [
+                'name'       => _('Nachricht schreiben'),
+                'title'      => _('Nachricht schreiben'),
+                'url'        => 'dispatch.php/massmail/quick/courses',
+                'dialogform' => true,
+                'multimode'  => true,
+                'partial'    => 'massmail.php'
+            ]
         ];
 
         if (!$GLOBALS['perm']->have_perm('admin')) {
diff --git a/app/controllers/massmail/message.php b/app/controllers/massmail/message.php
new file mode 100644
index 00000000000..3f7a009d4c3
--- /dev/null
+++ b/app/controllers/massmail/message.php
@@ -0,0 +1,394 @@
+<?php
+
+class Massmail_MessageController extends \AuthenticatedController
+{
+
+    public function before_filter(&$action, &$args)
+    {
+        parent::before_filter($action, $args);
+
+        if (!\MassMail\MassMailPermission::has(User::findCurrent()->id)) {
+            throw new AccessDeniedException();
+        }
+    }
+
+    public function index_action($id = null)
+    {
+        Navigation::activateItem('/messaging/massmail/message');
+        PageLayout::setTitle(_('Nachricht an Zielgruppe schreiben'));
+
+        $message = new \MassMail\MassMailMessage($id);
+
+        $temp_id = $id ?: uniqid(md5(time()));
+        $folder = $message->findFolder($temp_id);
+
+        // SearchType needed for course selection
+        $courseSearch = new StandardSearch('Seminar_id');
+
+        // SearchType needed for user
+        $userSearch = new StandardSearch('user_id');
+
+        $form = \Studip\Forms\Form::fromSORM(
+            $message,
+            [
+                'legend' => _('Grunddaten'),
+                'collapsed' => false,
+                'collapsable' => false,
+                'fields' => [
+                    'target' => [
+                        'type' => 'select',
+                        'required' => true,
+                        'label' => _('Zielgruppe'),
+                        'value' => $message->target ?? 'all',
+                        'options' => \MassMail\MassMailMessage::getTargets()
+                    ],
+                    'student_filters' => [
+                        'type' => 'userFilter',
+                        'label' => _('Auswahlfilter'),
+                        'if' => 'target === "students"',
+                        'context' => 'MassMail',
+                        'target' => 'students',
+                        ':key' => 'NaN',
+                        'store' => function($value, $input) {
+                            $filters = [];
+                            foreach ($value as $one) {
+                                $filter = new UserFilter($one['id'] ?? '');
+                                $filter->fields = [];
+                                foreach ($one['attributes']['fields'] as $field) {
+                                    $classname = $field['attributes']['type'];
+                                    $f = new $classname();
+                                    if (!empty($fiele['id'])) {
+                                        $f->setId($field['id']);
+                                    }
+                                    $f->setCompareOperator($field['attributes']['compare-operator']);
+                                    $f->setValue($field['attributes']['value']);
+                                    $filter->addField($f);
+                                }
+                                $filter->store();
+                                $connection = new \MassMail\MassMailFilter();
+                                $connection->filter_id = $filter->getId();
+                                $filters[] = $connection;
+                            }
+                            $input->getContextObject()->filters = $filters;
+                        }
+                    ],
+                    'employee_filters' => [
+                        'type' => 'userFilter',
+                        'label' => _('Auswahlfilter'),
+                        'if' => 'target === "employees"',
+                        'context' => 'MassMail',
+                        'target' => 'employees',
+                        ':key' => 'NaN',
+                        'store' => function($value, $input) {
+                            $filters = [];
+                            foreach ($value as $one) {
+                                $filter = new UserFilter($one['id'] ?? '');
+                                $filter->fields = [];
+                                foreach ($one['attributes']['fields'] as $field) {
+                                    $classname = $field['attributes']['type'];
+                                    $f = new $classname();
+                                    if (!empty($fiele['id'])) {
+                                        $f->setId($field['id']);
+                                    }
+                                    $f->setCompareOperator($field['attributes']['compare-operator']);
+                                    $f->setValue($field['attributes']['value']);
+                                    $filter->addField($f);
+                                }
+                                $filter->store();
+                                $connection = new \MassMail\MassMailFilter();
+                                $connection->filter_id = $filter->getId();
+                                $filters[] = $connection;
+                            }
+                            $input->getContextObject()->filters = $filters;
+                        }
+                    ],
+                    'semester' => [
+                        'type' => 'select',
+                        'label' => _('Semester wählen'),
+                        'value' => $message->config['semester'] ?? \Semester::findDefault()->id,
+                        'if' => 'target === "lecturers"',
+                        'options' => \MassMail\MassMailMessage::getSemesters(),
+                        'store' => function($value, $input) {
+                            if ($input->getContextObject()->target === 'lecturers') {
+                                $input->getContextObject()->config = ['semester' => $value];
+                            }
+                        }
+                    ],
+                    'courses' => [
+                        'type' => 'quicksearchList',
+                        'label' => _('Veranstaltungen wählen'),
+                        'value' => json_encode($message->config?->getArrayCopy()['courses'] ?? []),
+                        'if' => 'target === "courses"',
+                        'searchtype' => $courseSearch,
+                        'store' => function($value, $input) {
+                            if ($input->getContextObject()->target === 'courses') {
+                                $input->getContextObject()->config = [];
+                                $input->getContextObject()->config['courses'] = \Course::findAndMapMany(
+                                    function ($course) {
+                                        return ['id' => $course->id, 'name' => $course->getFullname()];
+                                    },
+                                    json_decode($value, true)
+                                );
+                            }
+                        }
+                    ],
+                    'course_perm' => [
+                        'type' => 'select',
+                        'label' => _('Berechtigungsebene wählen'),
+                        'value' => $message->config['perm'] ?? 'autor',
+                        'if' => 'target === "courses"',
+                        'options' => [
+                            'dozent' => get_title_for_status('dozent', 2, 1),
+                            'tutor' => get_title_for_status('tutor', 2, 1),
+                            'autor' => get_title_for_status('autor', 2, 1),
+                            'user' => get_title_for_status('user', 2, 1),
+                        ],
+                        'store' => function($value, $input) {
+                            if ($input->getContextObject()->target === 'courses') {
+                                $input->getContextObject()->config['perm'] = $value;
+                            }
+                        }
+                    ],
+                    'manual_usernames' => [
+                        'type' => 'textarea',
+                        'label' => _('Liste von Benutzernamen, durch Zeilenumbruch getrennt'),
+                        'if' => 'target === "usernames"',
+                        'value' => $message->config['usernames'] ?? '',
+                        'store' => function($value, $input) {
+                            if ($input->getContextObject()->target === 'usernames') {
+                                $input->getContextObject()->config = [];
+                                $input->getContextObject()->config['usernames'] = $value;
+                            }
+                        }
+                    ],
+                    'subject' => [
+                        'type' => 'text',
+                        'required' => true,
+                        'label' => _('Betreff'),
+                        'value' => $message->subject
+                    ],
+                    'message' => [
+                        'type' => 'serialWysiwyg',
+                        'required' => true,
+                        'label' => _('Nachricht'),
+                        'value' => $message->message,
+                        'markers' => json_encode(
+                            array_map(
+                                fn ($m) => $m->toArray(),
+                                \MassMail\MassMailMarker::findAll(
+                                    \MassMail\MassMailPermission::has(User::findCurrent()->id, true)
+                                )
+                            )
+                        )
+                    ]
+                ]
+            ],
+            $this->url_for('massmail/overview')
+        )->addSORM($message, [
+            'legend' => _('Weitere Einstellungen'),
+            'collapsable' => true,
+            'collapsed' => true,
+            'fields' => [
+                'author_id' => [
+                    'type' => 'hidden',
+                    'value' => User::findCurrent()->id
+                ],
+                'attachments' => [
+                    'type' => 'file',
+                    'label' => _('Dateianhänge auswählen'),
+                    'value' => $message->folder_id ?? $message->folder_id = $folder->id,
+                    'upload_url' => $this->url_for('massmail/message/attachments', $folder->id),
+                    'multiple' => true,
+                    'if' => $GLOBALS['ENABLE_EMAIL_ATTACHMENTS']
+                        ? 'true' : 'false',
+                    'store' => function($value, $input) {
+                        $input->getContextObject()->folder_id = $value;
+                    }
+                ],
+                'tokens' => [
+                    'type' => 'file',
+                    'label' => _('CSV mit Teilnahmecodes auswählen'),
+                    'value' => $message->folder_id ?? $message->folder_id = $folder->id,
+                    'upload_url' => $this->url_for('massmail/message/tokens', $message->folder_id),
+                    'accept' => '.csv,.txt',
+                    'if' => \MassMail\MassMailPermission::has(User::findCurrent()->id, true)
+                        ? 'true' : 'false',
+                    'store' => function($value, $input) {
+                        $input->getContextObject()->folder_id = $value;
+                    }
+                ],
+                'send_at_date' => [
+                    'type' => 'datetimepicker',
+                    'label' => _('Zu einem späteren Zeitpunkt senden'),
+                    'value' => $message->send_at_date ?? time()
+                ],
+                'send_as' => [
+                    'type' => 'select',
+                    'label' => ('Nachricht senden als'),
+                    'value' => $message->sender_id ?? User::findCurrent()->id,
+                    'if' => \MassMail\MassMailPermission::has(User::findCurrent()->id, true)
+                        ? 'true' : 'false',
+                    'options' => [
+                        User::findCurrent()->id => _('Von meiner Kennung verschicken'),
+                        'user_id' => _('Eine andere Person eintragen'),
+                        '____%system%____' => _('Anonym, mit "Stud.IP" als Absender')
+                    ],
+                    'store' => function($value, $input) {
+                        if ($value === User::findCurrent()->id || $value === '____%system%____') {
+                            $input->getContextObject()->sender_id = $value;
+                        }
+                    }
+                ],
+                'sender_id' => [
+                    'type' => 'quicksearch',
+                    'label' => _('Absender:in wählen'),
+                    'value' => $message->sender_id ?? '',
+                    'if' => 'send_as === "user_id"',
+                    'searchtype' => $userSearch,
+                    'store' => function($value, $input) {
+                        $sender_id = $input->getContextObject()->sender_id;
+                        if ($sender_id !== User::findCurrent()->id && $sender_id !== '____%system%____') {
+                            $input->sender_id = $value;
+                        }
+                    }
+                ],
+                'exclude_users' => [
+                    'type' => 'textarea',
+                    'label' => _('Liste von Benutzernamen, die die Nachricht nicht erhalten sollen'),
+                    'value' => $message->exclude_users ?? ''
+                ],
+                'cc' => [
+                    'type' => 'textarea',
+                    'label' => _('Liste von Benutzernamen, die die Nachricht als Kopie erhalten sollen'),
+                    'value' => $message->cc ?? ''
+                ],
+                'flags' => [
+                    'type' => 'radio',
+                    'label' => _('Besondere Kennzeichnung'),
+                    'value' => $message->is_template
+                        ? 'is_template'
+                        : ($message->protected ? 'protected' : ''),
+                    'options' => [
+                        '' => _('Keine besondere Kennzeichnung'),
+                        'is_template' => _('Nicht verschicken, sondern als Vorlage speichern'),
+                        'protected' => _('Auch nach dem Versand dauerhaft speichern')
+                    ],
+                    'store' => function($value, $input) {
+                        switch ($value) {
+                            case 'is_template':
+                                $input->getContextObject()->is_template = 1;
+                                $input->getContextObject()->protected = 0;
+                                break;
+                            case 'protected':
+                                $input->getContextObject()->is_template = 0;
+                                $input->getContextObject()->protected = 1;
+                                break;
+                            default:
+                                $input->getContextObject()->is_template = 0;
+                                $input->getContextObject()->protected = 0;
+                                break;
+                        }
+                    }
+                ]
+            ]
+        ])->addStoreCallback(function ($form) {
+            $message = $form->getLastPart()->getContextObject();
+
+            // Adjust folder range_id to the actual message id.
+            $folder = Folder::find($message->folder_id);
+            $folder->range_id = $message->id;
+            $folder->store();
+
+            // Create message tokens if necessary.
+            if ($message->hasMarkers('token')) {
+                foreach ($folder->getTypedFolder()->getFiles() as $ref) {
+                    if (isset($ref->file->metadata['is_token_file'])) {
+                        $file = fopen($ref->file->getPath(), 'r');
+                        while (!feof($file)) {
+                            $token = fgets($file);
+                            $t = new \MassMail\MassMailToken();
+                            $t->message_id = $message->id;
+                            $t->token = $token;
+                            $t->store();
+                        }
+                    }
+                }
+            }
+        })->autoStore();
+
+        $this->render_form($form);
+    }
+
+    public function delete_action(int $id)
+    {
+        $message = \MassMail\MassMailMessage::find($id);
+
+        if (
+            !$message
+            || (
+                $message->author_id !== User::findCurrent()->id
+                && !\MassMail\MassMailPermission::has(User::findCurrent()->id, true)
+            )
+        ) {
+            throw new AccessDeniedException();
+        }
+
+        if ($message->delete() !== false) {
+            PageLayout::postSuccess(_('Die Nachricht wurde gelöscht.'));
+        } else {
+            PageLayout::postError(_('Die Nachricht konnte nicht gelöscht werden.'));
+        }
+
+        $this->relocate('massmail/overview');
+    }
+
+    public function attachments_action(string $folder_id)
+    {
+        if (!$GLOBALS['ENABLE_EMAIL_ATTACHMENTS']) {
+            throw new AccessDeniedException();
+        }
+
+        $folder = Folder::find($folder_id)->getTypedFolder();
+        $uploaded = FileManager::handleFileUpload($_FILES['attachments'], $folder);
+
+        if (!empty($uploaded['error'])) {
+            $this->set_status(400);
+            $this->render_text(implode('<br>' . $uploaded['error']));
+        } else {
+            $this->render_nothing();
+        }
+    }
+
+    public function tokens_action(string $folder_id)
+    {
+        if (!\MassMail\MassMailPermission::has(User::findCurrent()->id, true)) {
+            throw new AccessDeniedException();
+        }
+
+        $data = [
+            'name'     => [$_FILES['tokens']['name']],
+            'tmp_name' => [$_FILES['tokens']['tmp_name']],
+            'type'     => [$_FILES['tokens']['type']],
+            'error'    => [$_FILES['tokens']['error']],
+            'size'     => [$_FILES['tokens']['size']],
+        ];
+
+        $folder = Folder::find($folder_id)->getTypedFolder();
+        $uploaded = FileManager::handleFileUpload($data, $folder);
+
+        if (!empty($uploaded['error'])) {
+            $this->set_status(400);
+            $this->render_text(implode('<br>' . $uploaded['error']));
+        } else {
+
+            // Set metadata for created file, indicating that this is a file with message tokens.
+            foreach ($uploaded['files'] as $ref) {
+                $ref->file->metadata = ['is_token_file' => true];
+                $ref->file->store();
+            }
+
+            $this->render_nothing();
+        }
+    }
+
+}
diff --git a/app/controllers/massmail/overview.php b/app/controllers/massmail/overview.php
new file mode 100644
index 00000000000..db71d35f8df
--- /dev/null
+++ b/app/controllers/massmail/overview.php
@@ -0,0 +1,32 @@
+<?php
+
+class Massmail_OverviewController extends \AuthenticatedController
+{
+
+    public function before_filter(&$action, &$args)
+    {
+        parent::before_filter($action, $args);
+
+        if (!\MassMail\MassMailPermission::has(User::findCurrent()->id)) {
+            throw new AccessDeniedException();
+        }
+
+        Navigation::activateItem('/messaging/massmail/overview');
+
+        Sidebar::Get()->addWidget(new VueWidget('message-views'));
+
+        $this->render_vue_app(
+            Studip\VueApp::create('massmail/MassMailMessagesList')
+        );
+    }
+
+    public function index_action($id = null)
+    {
+        PageLayout::setTitle(_('Nachrichten'));
+
+        $this->render_vue_app(
+            Studip\VueApp::create('massmail/MassMailMessagesList')
+        );
+    }
+
+}
diff --git a/app/controllers/massmail/permissions.php b/app/controllers/massmail/permissions.php
new file mode 100644
index 00000000000..bd8200bf642
--- /dev/null
+++ b/app/controllers/massmail/permissions.php
@@ -0,0 +1,174 @@
+<?php
+
+class Massmail_PermissionsController extends \AuthenticatedController
+{
+
+    public function before_filter(&$action, &$args)
+    {
+        parent::before_filter($action, $args);
+
+        if (!\MassMail\MassMailPermission::has(User::findCurrent()->id, true)) {
+            throw new AccessDeniedException();
+        }
+
+        Navigation::activateItem('/messaging/massmail/permissions');
+    }
+
+    /**
+     * Lists all existing permissions.
+     * @return void
+     * @throws AccessDeniedException
+     */
+    public function index_action()
+    {
+        PageLayout::setTitle(_('Berechtigungen für den Nachrichtenversand an Zielgruppen'));
+
+        $this->permissions = \MassMail\MassMailPermission::findBySQL("1");
+        usort(
+            $this->permissions,
+            fn ($a, $b) => strnatcasecmp($a->institute_name, $b->institute_name)
+        );
+
+        $sidebar = Sidebar::Get();
+        $actions = new ActionsWidget();
+        $actions->addLink(
+            _('Neue Berechtigung vergeben'),
+            $this->url_for('massmail/permissions/edit'),
+            Icon::create('add'),
+        )->asDialog('size=medium');
+        $sidebar->addWidget($actions);
+
+        $this->render_vue_app(
+            Studip\VueApp::create('massmail/MassMailPermissions')
+        );
+    }
+
+    /**
+     * Provides a form for entering or editing a massmail permission.
+     * @param int $id which permission to edit, create a new one if $id is 0
+     * @return void
+     * @throws AccessDeniedException
+     */
+    public function edit_action(int $id = 0)
+    {
+        $permission = new \MassMail\MassMailPermission($id);
+
+        PageLayout::setTitle(
+            $permission->isNew()
+                ? _('Berechtigung erstellen')
+                : _('Berechtigung bearbeiten')
+        );
+
+        $institutes = [];
+        foreach (Institute::getInstitutes() as $one) {
+            $institutes[$one['Institut_id']] = $one['Name'];
+        }
+
+        $degrees = [];
+        foreach (Degree::findBySQL("1 ORDER BY `name`") as $one) {
+            $degrees[$one->id] = $one->name;
+        }
+
+        $subjects = [];
+        foreach (StudyCourse::findBySQL("1 ORDER BY `name`") as $one) {
+            $subjects[$one->id] = $one->name;
+        }
+
+        $form = \Studip\Forms\Form::fromSORM(
+            $permission,
+            [
+                'fields' => [
+                    'institute_id' => [
+                        'type' => 'select',
+                        'required' => true,
+                        'label' => _('Einrichtung'),
+                        'value' => $permission->institute_id,
+                        'options' => $institutes
+                    ],
+                    'min_perm' => [
+                        'type' => 'select',
+                        'required' => true,
+                        'label' => _('Benötigte Rechte'),
+                        'value' => $permission->min_perm,
+                        'options' => [
+                            'admin' => 'admin',
+                            'dozent' => 'dozent',
+                            'tutor' => 'tutor'
+                        ]
+                    ],
+                    'allowed_degrees' => [
+                        'type' => 'checkboxCollection',
+                        'collapsable' => true,
+                        'label' => _('Erlaubte Abschlüsse'),
+                        'value' => $permission->allowed_degrees->pluck('id'),
+                        'options' => $degrees
+                    ],
+                    'allowed_subjects' => [
+                        'type' => 'checkboxCollection',
+                        'collapsable' => true,
+                        'label' => _('Erlaubte Fächer'),
+                        'value' => $permission->allowed_subjects->pluck('id'),
+                        'options' => $subjects
+                    ],
+                    'allowed_institutes' => [
+                        'type' => 'checkboxCollection',
+                        'collapsable' => true,
+                        'label' => _('Erlaubte Einrichtungen (außer den eigenen)'),
+                        'value' => $permission->allowed_institutes->pluck('id'),
+                        'options' => $institutes
+                    ]
+                ]
+            ]
+        )->setURL($this->url_for('massmail/permissions/store', $id));
+
+        $this->render_form($form);
+    }
+
+    /**
+     * Stores permission data by editing an existing or creating a new one.
+     * @param int $id the permission to store
+     * @return void
+     * @throws AccessDeniedException
+     */
+    public function store_action(int $id = 0)
+    {
+        CSRFProtection::verifyUnsafeRequest();
+        $permission = new \MassMail\MassMailPermission($id);
+        $permission->institute_id = Request::option('institute_id');
+        $permission->min_perm = Request::get('min_perm');
+        $permission->allowed_degrees = Degree::findMany(Request::optionArray('allowed_degrees'));
+        $permission->allowed_subjects = StudyCourse::findMany(Request::optionArray('allowed_subjects'));
+        $permission->allowed_institutes = Institute::findMany(Request::optionArray('allowed_institutes'));
+
+        if ($permission->store() !== false) {
+            PageLayout::postSuccess('Die Daten wurden gespeichert.');
+        } else {
+            PageLayout::postError('Die Daten konnten nicht gespeichert werden.');
+        }
+
+        $this->relocate('massmail/permissions');
+    }
+
+    /**
+     * Deletes the given permission entry.
+     * @param int $id the permission to delete
+     * @return void
+     * @throws AccessDeniedException
+     */
+    public function delete_action(int $id)
+    {
+        $permission = \MassMail\MassMailPermission::find($id);
+        if ($permission) {
+            if ($permission->delete()) {
+                PageLayout::postSuccess(_('Die Berechtigung wurde gelöscht.'));
+            } else {
+                PageLayout::postError(_('Die Berechtigung konnte nicht gelöscht werden.'));
+            }
+        } else {
+            PageLayout::postError(_('Die Berechtigung wurde nicht gefunden.'));
+        }
+
+        $this->relocate('massmail/permissions');
+    }
+
+}
diff --git a/app/controllers/massmail/quick.php b/app/controllers/massmail/quick.php
new file mode 100644
index 00000000000..b7cdf94400b
--- /dev/null
+++ b/app/controllers/massmail/quick.php
@@ -0,0 +1,90 @@
+<?php
+
+/**
+ * quick.php Controller for quick creation of massmails to selected courses.
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ *
+ * @author      Thomas Hackl
+ * @license     GPL2 or any later version
+ * @since       Stud.IP 6.0
+ */
+
+class Massmail_QuickController extends \AuthenticatedController
+{
+
+    public function courses_action()
+    {
+        $GLOBALS['perm']->check('admin');
+
+        Navigation::activateItem('/messaging/massmail/message');
+        PageLayout::setTitle(_('Nachricht an Zielgruppe schreiben'));
+
+        $message = new \MassMail\MassMailMessage();
+        $message->target = 'courses';
+        $message->sender_id = $message->author_id = User::findCurrent()->id;
+        $message->config = ['perm' => 'autor', 'courses' => Request::optionArray('courses')];
+
+        $courses = Request::optionArray('courses');
+
+        $form = \Studip\Forms\Form::fromSORM(
+            $message,
+            [
+                'legend' => _('Grunddaten'),
+                'collapsed' => false,
+                'collapsable' => false,
+                'fields' => [
+                    'courses' => [
+                        'type' => 'hidden',
+                        'value' => implode(',', $courses),
+                        'store' => function($value, $input) {
+                            $input->getContextObject()->config = [];
+                            $input->getContextObject()->config['courses'] = explode(',', $value);
+                        }
+                    ],
+                    'course_perm' => [
+                        'type' => 'select',
+                        'label' => _('Berechtigungsebene wählen'),
+                        'value' => 'autor',
+                        'options' => [
+                            'dozent' => get_title_for_status('dozent', 2, 1),
+                            'tutor' => get_title_for_status('tutor', 2, 1),
+                            'autor' => get_title_for_status('autor', 2, 1),
+                            'user' => get_title_for_status('user', 2, 1),
+                        ],
+                        'store' => function($value, $input) {
+                            $input->getContextObject()->config['perm'] = $value;
+                        }
+                    ],
+                    'subject' => [
+                        'type' => 'text',
+                        'required' => true,
+                        'label' => _('Betreff'),
+                        'value' => $message->subject
+                    ],
+                    'message' => [
+                        'type' => 'serialWysiwyg',
+                        'required' => true,
+                        'label' => _('Nachricht'),
+                        'value' => $message->message,
+                        'markers' => json_encode(
+                            array_map(
+                                fn ($m) => $m->toArray(),
+                                \MassMail\MassMailMarker::findAll(
+                                    \MassMail\MassMailPermission::has(User::findCurrent()->id, true)
+                                )
+                            )
+                        )
+                    ]
+                ]
+            ],
+            $this->url_for('admin/courses')
+        )->autoStore();
+
+        $this->render_form($form);
+    }
+
+}
diff --git a/app/controllers/massmail/settings.php b/app/controllers/massmail/settings.php
new file mode 100644
index 00000000000..1b6c626454a
--- /dev/null
+++ b/app/controllers/massmail/settings.php
@@ -0,0 +1,109 @@
+<?php
+
+class Massmail_SettingsController extends \AuthenticatedController
+{
+
+    public function before_filter(&$action, &$args)
+    {
+        parent::before_filter($action, $args);
+
+        if (!\MassMail\MassMailPermission::has(User::findCurrent()->id, true)) {
+            throw new AccessDeniedException();
+        }
+
+        Navigation::activateItem('/messaging/massmail/settings');
+    }
+
+    /**
+     * Lists all existing permissions.
+     * @return void
+     * @throws AccessDeniedException
+     */
+    public function index_action()
+    {
+        PageLayout::setTitle(_('Einstellungen für den Nachrichtenversand an Zielgruppen'));
+
+        $categories = [];
+        foreach (SemClass::getClasses() as $class) {
+            $categories[$class['id']] = $class['name'];
+        }
+
+        $form = \Studip\Forms\Form::create();
+        $form->setURL($this->url_for('massmail/settings/store'));
+        $config = new \Studip\Forms\Fieldset(_('Konfiguration'));
+        $config->addInput(
+            new \Studip\Forms\NumberInput(
+                'cleanup',
+                _('Anzahl Tage, nach denen bereits verschickte Nachrichten gelöscht werden (0 bedeutet nie)'),
+                Config::get()->MASSMAIL_GC_DAYS,
+                ['min' => 0]
+            )
+        );
+        $form->addPart($config);
+
+        $form->addInput(
+            new \Studip\Forms\CheckboxCollectionInput(
+                'categories',
+                _('Veranstaltungskategorien, die für die Ermittlung aktiver Lehrender berücksichtigt werden'),
+                Config::get()->MASSMAIL_LECTURER_SEM_CATEGORIES,
+                ['options' => $categories]
+            )
+        );
+
+        $task = CronjobTask::findOneByClass(SendMassmailsJob::class);
+        $job = CronjobSchedule::findOneByTask_id($task->id);
+
+        $cron = new \Studip\Forms\Fieldset(_('Cronjob'));
+
+        if (!$task->active || !$job->active) {
+            $cron->addInput(
+                new \Studip\Forms\InfoInput(
+                    'inactive',
+                    _('Achtung: Kein Versand'),
+                    _('Der automatische Versand ist nicht aktiviert!')
+                )
+            );
+        }
+
+        $cron->addInput(
+            new \Studip\Forms\NumberInput(
+                'minutes',
+                _('Abstand des Versands anstehender Nachrichten in Minuten'),
+                abs($job->minute),
+                ['min' => 1, 'max' => 59]
+            )
+        );
+        $form->addPart($cron);
+
+        $this->render_form($form);
+    }
+
+    /**
+     * Stores the global massmail settings..
+     * @return void
+     * @throws AccessDeniedException
+     */
+    public function store_action()
+    {
+        CSRFProtection::verifyUnsafeRequest();
+
+        Config::get()->store(
+            'MASSMAIL_GC_DAYS',
+            Request::int('cleanup', 7)
+        );
+        Config::get()->store(
+            'MASSMAIL_LECTURER_SEM_CATEGORIES',
+            Request::intArray('categories')
+        );
+
+        $task = CronjobTask::findOneByClass(SendMassmailsJob::class);
+        $job = CronjobSchedule::findOneByTask_id($task->id);
+        $job->minute = -1 * abs(Request::int('minutes'));
+        $job->store();
+
+        PageLayout::postSuccess('Die Einstellungen wurden gespeichert.');
+
+        $this->relocate('massmail/settings');
+    }
+
+}
diff --git a/app/views/admin/courses/massmail.php b/app/views/admin/courses/massmail.php
new file mode 100644
index 00000000000..e871040ee54
--- /dev/null
+++ b/app/views/admin/courses/massmail.php
@@ -0,0 +1,10 @@
+<?php
+/**
+ * @var Course $course
+ */
+?>
+<label>
+    <input name="courses[]" type="checkbox" value="<?= htmlReady($course->id) ?>"
+           aria-label="<?= htmlReady(sprintf(_('Nachricht an Teilnehmende der Veranstaltung %s senden'),
+               $course->getFullName())) ?>">
+</label>
diff --git a/db/migrations/6.0.32_integrate_garuda_plugin.php b/db/migrations/6.0.32_integrate_garuda_plugin.php
new file mode 100644
index 00000000000..2e0861476a6
--- /dev/null
+++ b/db/migrations/6.0.32_integrate_garuda_plugin.php
@@ -0,0 +1,285 @@
+<?php
+require_once 'lib/models/MassMail/MassMailPermission.php';
+require_once 'lib/cronjobs/send_massmails.php';
+
+return new class extends Migration
+{
+    use DatabaseMigrationTrait;
+
+    public function description()
+    {
+        return 'Integrate the functionality from the Garuda plugin into the Stud.IP core.';
+    }
+
+    protected function up()
+    {
+        // Messages and templates
+        DBManager::get()->exec("CREATE TABLE IF NOT EXISTS `massmail_messages` (
+            `message_id` INT NOT NULL AUTO_INCREMENT,
+            `sender_id` CHAR(32) COLLATE latin1_bin,
+            `author_id` CHAR(32) COLLATE latin1_bin NOT NULL,
+            `send_at_date` INT,
+            `target` ENUM('all', 'students', 'employees', 'lecturers', 'courses', 'usernames') COLLATE latin1_bin,
+            `config` LONGTEXT,
+            `exclude_users` LONGTEXT,
+            `cc` TEXT,
+            `subject` VARCHAR(255) NOT NULL,
+            `message` TEXT NOT NULL,
+            `folder_id` CHAR(32) COLLATE latin1_bin,
+            `is_template` TINYINT(1) NOT NULL DEFAULT 0,
+            `locked` TINYINT(1) NOT NULL DEFAULT 0,
+            `sent` TINYINT(1) NOT NULL DEFAULT 0,
+            `protected` TINYINT(1) NOT NULL DEFAULT 0,
+            `mkdate` INT UNSIGNED NOT NULL,
+            `chdate` INT UNSIGNED NOT NULL,
+            PRIMARY KEY (`message_id`),
+            INDEX author_id (`author_id`)
+        )");
+
+        // Permissions for using this functionality
+        DBManager::get()->exec("CREATE TABLE IF NOT EXISTS `massmail_permissions` (
+            `permission_id` INT NOT NULL AUTO_INCREMENT,
+            `institute_id` CHAR(32) COLLATE latin1_bin NOT NULL,
+            `min_perm` ENUM ('admin', 'dozent', 'tutor', 'autor') COLLATE latin1_bin NOT NULL DEFAULT 'admin',
+            `mkdate` INT UNSIGNED NOT NULL,
+            `chdate` INT UNSIGNED NOT NULL,
+            PRIMARY KEY (`permission_id`),
+            UNIQUE INDEX institute_id (`institute_id`)
+        )");
+
+        // Allowed degrees
+        DBManager::get()->exec("CREATE TABLE IF NOT EXISTS `massmail_permission_degree` (
+            `permission_id` INT NOT NULL,
+            `degree_id` CHAR(32) COLLATE latin1_bin NOT NULL,
+            `mkdate` INT UNSIGNED NOT NULL,
+            PRIMARY KEY (`permission_id`, `degree_id`),
+            INDEX degree_id (`degree_id`)
+        )");
+
+        // Allowed subjects of study
+        DBManager::get()->exec("CREATE TABLE IF NOT EXISTS `massmail_permission_subject` (
+            `permission_id` INT NOT NULL,
+            `subject_id` CHAR(32) COLLATE latin1_bin NOT NULL,
+            `mkdate` INT UNSIGNED NOT NULL,
+            PRIMARY KEY (`permission_id`, `subject_id`),
+            INDEX subject_id (`subject_id`)
+        )");
+
+        // Allowed institutes
+        DBManager::get()->exec("CREATE TABLE IF NOT EXISTS `massmail_permission_institute` (
+            `permission_id` INT NOT NULL,
+            `institute_id` CHAR(32) COLLATE latin1_bin NOT NULL,
+            `mkdate` INT UNSIGNED NOT NULL,
+            PRIMARY KEY (`permission_id`, `institute_id`),
+            INDEX institute_id (`institute_id`)
+        )");
+
+        // User filters
+        DBManager::get()->exec("CREATE TABLE IF NOT EXISTS `massmail_filter` (
+            `message_id` INT NOT NULL,
+            `filter_id` CHAR(32) COLLATE latin1_bin NOT NULL,
+            `mkdate` INT UNSIGNED NOT NULL,
+            PRIMARY KEY (`message_id`, `filter_id`),
+            INDEX filter_id (`filter_id`)
+        )");
+
+        // User-specific tokens
+        DBManager::get()->exec("CREATE TABLE IF NOT EXISTS `massmail_tokens` (
+            `token_id` INT NOT NULL AUTO_INCREMENT,
+            `message_id` INT NOT NULL,
+            `user_id` CHAR(32) COLLATE latin1_bin,
+            `token` VARCHAR(1024) NOT NULL,
+            `mkdate` INT UNSIGNED NOT NULL,
+            PRIMARY KEY (`token_id`),
+            INDEX message_id (`message_id`)
+        )");
+
+        // Serial mail markers
+        DBManager::get()->exec("CREATE TABLE IF NOT EXISTS `massmail_markers` (
+            `marker_id` INT NOT NULL AUTO_INCREMENT,
+            `marker` VARCHAR(255) NOT NULL,
+            `name` VARCHAR(255) NOT NULL,
+            `type` ENUM('text', 'database', 'function', 'token') COLLATE latin1_bin,
+            `description` TEXT,
+            `root_only` TINYINT(1) UNSIGNED DEFAULT 0,
+            `replacement` TEXT,
+            `replacement_female` TEXT,
+            `replacement_unknown` TEXT,
+            `position` TINYINT(1) UNSIGNED,
+            `mkdate` INT UNSIGNED NOT NULL,
+            `chdate` INT UNSIGNED NOT NULL,
+            PRIMARY KEY (`marker_id`)
+        )");
+
+        $markers = [
+            [
+                'marker' => 'FULLNAME',
+                'name' => 'Voller Name',
+                'type' => 'database',
+                'description' => _('Hier wird der volle Name der jeweiligen Person eingesetzt, z.B. "Prof. Max Mustermann, PhD".'),
+                'replacement' => 'user_info.title_front {{FIRSTNAME}} {{LASTNAME}} user_info.title_rear',
+                'position' => 2
+            ],
+            [
+                'marker' => 'FIRSTNAME',
+                'name' => 'Vorname',
+                'type' => 'database',
+                'description' => _('Hier wird der Vorname der jeweiligen Person eingesetzt.'),
+                'replacement' => 'auth_user_md5.Vorname',
+                'position' => 3
+            ],
+            [
+                'marker' => 'LASTNAME',
+                'name' => 'Nachname',
+                'type' => 'database',
+                'description' => _('Hier wird der Nachname der jeweiligen Person eingesetzt.'),
+                'replacement' => 'auth_user_md5.Nachname',
+                'position' => 4
+            ],
+            [
+                'marker' => 'USERNAME',
+                'name' => 'Benutzername',
+                'type' => 'database',
+                'description' => _('Hier wird der Benutzername der jeweiligen Person eingesetzt.'),
+                'replacement' => 'auth_user_md5.username',
+                'position' => 5
+            ],
+            [
+                'marker' => 'SEHRGEEHRTE',
+                'name' => 'Anrede mit vollem Namen',
+                'type' => 'text',
+                'description' => _('Hier wird eine Anrede erzeugt: "Sehr geehrte Michaela Musterfrau" bzw. "Sehr geehrter Max Mustermann".'),
+                'replacement' => 'Sehr geehrter {{FULLNAME}}',
+                'replacement_female' => 'Sehr geehrte {{FULLNAME}}',
+                'replacement_unknown' => 'Sehr geehrte/r {{FULLNAME}}',
+                'position' => 1
+            ],
+            [
+                'marker' => 'DEARSIRMADAM',
+                'name' => 'Anrede (englisch) mit vollem Namen',
+                'type' => 'text',
+                'description' => _('Creates a Salutation: "Dear Jane Doe" or "Dear John Doe".'),
+                'replacement' => 'Dear {{FULLNAME}}',
+                'position' => 6
+            ],
+            [
+                'marker' => 'TOKEN',
+                'name' => 'Personalisierter Code o.ä.',
+                'type' => 'token',
+                'description' => _('Hier wird ein persönlicher Teilnahmecode o.ä. aus einer hochgeladenen Datei eingesetzt.'),
+                'replacement' => 'massmail_tokens.token',
+                'root_only' => 1,
+                'position' => 7
+            ]
+        ];
+
+        foreach ($markers as $data) {
+            \MassMail\MassMailMarker::create($data);
+        }
+
+        if (empty(RolePersistence::getRoleIdByName(\MassMail\MassMailPermission::MASSMAIL_ROOT_ROLE))) {
+            RolePersistence::saveRole(
+                new Role(Role::UNKNOWN_ROLE_ID, \MassMail\MassMailPermission::MASSMAIL_ROOT_ROLE)
+            );
+        }
+
+        DBManager::get()->exec("INSERT IGNORE INTO `config`
+             (`field`, `value`, `type`, `range`, `section`, `mkdate`, `chdate`, `description`)
+             VALUES
+             (
+              'MASSMAIL_LECTURER_SEM_CATEGORIES',
+              '[1]',
+              'array',
+              'global',
+              'MassMail',
+              UNIX_TIMESTAMP(),
+              UNIX_TIMESTAMP(),
+              'Veranstaltungskategorien, die für die Ermittlung aktiver Lehrender berücksichtigt werden'
+             )"
+        );
+        DBManager::get()->exec("INSERT IGNORE INTO `config`
+             (`field`, `value`, `type`, `range`, `section`, `mkdate`, `chdate`, `description`)
+             VALUES
+             (
+              'MASSMAIL_GC_DAYS',
+              '7',
+              'integer',
+              'global',
+              'MassMail',
+              UNIX_TIMESTAMP(),
+              UNIX_TIMESTAMP(),
+              'Anzahl Tage, nach denen bereits verschickte Nachrichten aus der Datenbank entfernt werden (0 bedeutet nie)'
+             )"
+        );
+
+        SendMassmailsJob::register()->schedulePeriodic(-15)->activate();
+
+        /*
+         * Extend userfilter table so that we know from which context a specific UserFilter comes from,
+         * allowing us to check permissions for editing.
+         */
+        if (!$this->columnExists('userfilter', 'range_id') && !$this->columnExists('userfilter', 'range_type')) {
+            DBManager::get()->exec("ALTER TABLE `userfilter`
+                ADD `range_id` VARCHAR(32) COLLATE `latin1_bin` NOT NULL AFTER `filter_id`,
+                ADD `range_type` VARCHAR(255) COLLATE `latin1_bin` NOT NULL AFTER `range_id`");
+        }
+
+        /*
+         * Set context values for existing userfilters (we only need to consider filters from admission rules
+         * as only those are part of the core so far)
+         */
+
+        // First: filters from ConditionalAdmissions
+        $conditions = DBManager::get()->fetchAll(
+            "SELECT DISTINCT c.`filter_id`, r.`set_id` FROM `admission_condition` c
+             JOIN `courseset_rule` r USING (`rule_id`)"
+        );
+        // Second: filters from PreferentialAdmissions
+        $preferential = DBManager::get()->fetchAll(
+            "SELECT DISTINCT p.`condition_id` AS filter_id, r.`set_id` FROM `prefadmission_condition` p
+             JOIN `courseset_rule` r USING (`rule_id`)"
+        );
+        foreach (array_merge($conditions, $preferential) as $filter) {
+            DBManager::get()->execute(
+                "UPDATE `userfilter` SET `range_id` = :range, `range_type` = :type WHERE `filter_id` = :filter",
+                ['range' => $filter['set_id'], 'type' => CourseSet::class, 'filter' => $filter['filter_id']]
+            );
+        }
+    }
+
+    protected function down()
+    {
+        $tables = [
+            'massmail_messages',
+            'massmail_permissions',
+            'massmail_permission_degree',
+            'massmail_permission_subject',
+            'massmail_permission_institute',
+            'massmail_filter',
+            'massmail_tokens',
+            'massmail_markers'
+        ];
+        DBManager::get()->execute(
+            "DROP TABLE IF EXISTS `" . implode('`,`', $tables) . "`");
+
+        $id = RolePersistence::getRoleIdByName(\MassMail\MassMailPermission::MASSMAIL_ROOT_ROLE);
+        if (!empty($id)) {
+            RolePersistence::deleteRole(new Role($id));
+        }
+
+        DBManager::get()->execute(
+            "DELETE FROM `config_values` WHERE `field` = :field",
+            ['field' => 'MASSMAIL_LECTURER_SEM_CATEGORIES']
+        );
+        DBManager::get()->execute(
+            "DELETE FROM `config` WHERE `field` = :field",
+            ['field' => 'MASSMAIL_LECTURER_SEM_CATEGORIES']
+        );
+
+        SendMassmailsJob::unregister();
+
+        if ($this->columnExists('userfilter', 'range_id') && $this->columnExists('userfilter', 'range_type')) {
+            DBManager::get()->exec("ALTER TABLE `userfilter` DROP `range_id`, DROP `range_type`");
+        }
+    }
+};
diff --git a/lib/admissionrules/conditionaladmission/ConditionalAdmission.php b/lib/admissionrules/conditionaladmission/ConditionalAdmission.php
index ab26cc29948..10bcb995fe5 100644
--- a/lib/admissionrules/conditionaladmission/ConditionalAdmission.php
+++ b/lib/admissionrules/conditionaladmission/ConditionalAdmission.php
@@ -446,6 +446,7 @@ class ConditionalAdmission extends AdmissionRule
         $groupqueries = [];
         $groupparameters = [];
         foreach ($this->ungrouped_conditions as $condition) {
+            $condition->setRange(CourseSet::class, $this->courseSetId);
             // Store each ungrouped condition...
             $condition->store();
             $queries[] = "(?, ?, ?, ?)";
@@ -460,6 +461,7 @@ class ConditionalAdmission extends AdmissionRule
             $groupparameters[] = $conditiongroup_id;
             $groupparameters[] = $this->quota[$conditiongroup_id];
             foreach ($conditions as $condition) {
+                $condition->setRange(CourseSet::class, $this->courseSetId);
                 // Store each group of conditions...
                 $condition->store();
                 $queries[] = "(?, ?, ?, ?)";
diff --git a/lib/admissionrules/preferentialadmission/PreferentialAdmission.php b/lib/admissionrules/preferentialadmission/PreferentialAdmission.php
index 23633d6d62a..9617f2bb784 100644
--- a/lib/admissionrules/preferentialadmission/PreferentialAdmission.php
+++ b/lib/admissionrules/preferentialadmission/PreferentialAdmission.php
@@ -487,6 +487,7 @@ class PreferentialAdmission extends AdmissionRule
         $parameters = [];
         if ($this->conditions) {
             foreach ($this->conditions as $condition) {
+                $condition->setRange(CourseSet::class, $this->courseSetId);
                 // Store each condition...
                 $condition->store();
                 $queries[] = "(?, ?, ?)";
diff --git a/lib/classes/JsonApi/RouteMap.php b/lib/classes/JsonApi/RouteMap.php
index b13887f3af3..ec3668d1b2c 100644
--- a/lib/classes/JsonApi/RouteMap.php
+++ b/lib/classes/JsonApi/RouteMap.php
@@ -133,6 +133,7 @@ class RouteMap
         $this->addAuthenticatedForumRoutes($group);
         $this->addAuthenticatedInstitutesRoutes($group);
         $this->addAuthenticatedLtiRoutes($group);
+        $this->addAuthenticatedMassMailRoutes($group);
         $this->addAuthenticatedMessagesRoutes($group);
         $this->addAuthenticatedNewsRoutes($group);
         $this->addAuthenticatedStockImagesRoutes($group);
@@ -305,6 +306,14 @@ class RouteMap
         $group->get('/lti-tools', Routes\Lti\LtiToolsIndex::class);
     }
 
+
+    private function addAuthenticatedMassMailRoutes(RouteCollectorProxy $group): void
+    {
+        $group->get('/mass-mails/messages', Routes\MassMail\MassMailMessagesIndex::class);
+        $group->get('/mass-mails/permissions', Routes\MassMail\MassMailPermissionsIndex::class);
+        $group->get('/mass-mails/permissions/{id}', Routes\MassMail\MassMailPermissionsShow::class);
+    }
+
     private function addAuthenticatedNewsRoutes(RouteCollectorProxy $group): void
     {
         $group->post('/courses/{id}/news', Routes\News\CourseNewsCreate::class);
diff --git a/lib/classes/JsonApi/Routes/MassMail/Authority.php b/lib/classes/JsonApi/Routes/MassMail/Authority.php
new file mode 100644
index 00000000000..20a79ce7294
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/MassMail/Authority.php
@@ -0,0 +1,30 @@
+<?php
+
+namespace JsonApi\Routes\MassMail;
+
+use MassMail\MassMailPermission;
+use User;
+
+class Authority
+{
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public static function canShowMassMailPermissions(User $user, MassMailPermission $permission): bool
+    {
+        return MassMailPermission::has($user->id, true);
+    }
+
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public static function canIndexMassMailPermissions(User $user): bool
+    {
+        return MassMailPermission::has($user->id, true);
+    }
+
+    public static function canIndexMassMailMessages(User $user): bool
+    {
+        return MassMailPermission::has($user->id);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/MassMail/MassMailMessagesIndex.php b/lib/classes/JsonApi/Routes/MassMail/MassMailMessagesIndex.php
new file mode 100644
index 00000000000..46f2c982df8
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/MassMail/MassMailMessagesIndex.php
@@ -0,0 +1,71 @@
+<?php
+
+namespace JsonApi\Routes\MassMail;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use MassMail\MassMailMessage;
+use MassMail\MassMailPermission;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use JsonApi\JsonApiController;
+
+class MassMailMessagesIndex extends JsonApiController
+{
+    protected $allowedPagingParameters = ['offset', 'limit'];
+    protected $allowedFilteringParameters = ['templates', 'queued', 'protected', 'locked', 'sent'];
+    protected $allowedIncludePaths = ['author', 'sender', 'filters'];
+
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        if (!Authority::canIndexMassMailMessages($this->getUser($request))) {
+            throw new AuthorizationFailedException();
+        }
+
+        $filters = $this->getContextFilters();
+
+        [$offset, $limit] = $this->getOffsetAndLimit();
+
+        $sql = "`is_template` = :template AND `locked` = :locked AND `sent` = :sent ".
+            "ORDER BY `chdate` DESC";
+        $parameters = [
+            'template' => $filters['templates'] ? 1 : 0,
+            'locked' => $filters['locked'] ? 1 : 0,
+            'sent' => $filters['sent'] ? 1 : 0
+        ];
+
+        if ($filters['protected']) {
+            $sql = "`protected` = :protected AND " . $sql;
+            $parameters['protected'] = 1;
+        }
+
+        if (!MassMailPermission::has($this->getUser($request)->id, true) || $filters['templates']) {
+            $sql = "`author_id` = :author AND " . $sql;
+            $parameters['author'] = $this->getUser($request)->id;
+        }
+
+        $total = MassMailMessage::countBySQL($sql, $parameters);
+        $messages = MassMailMessage::findBySQL(
+            $sql . " LIMIT :limit OFFSET :offset",
+            array_merge(
+                $parameters,
+                ['limit' => $limit,'offset' => $offset]
+            ));
+
+        return $this->getPaginatedContentResponse($messages, $total);
+    }
+
+    private function getContextFilters()
+    {
+        $defaults = [
+            'templates' => false,
+            'queued' => false,
+            'protected' => false,
+            'locked' => false,
+            'sent' => false
+        ];
+
+        $filtering = $this->getQueryParameters()->getFilteringParameters() ?: [];
+
+        return array_merge($defaults, $filtering);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/MassMail/MassMailPermissionsIndex.php b/lib/classes/JsonApi/Routes/MassMail/MassMailPermissionsIndex.php
new file mode 100644
index 00000000000..1d8f3abe24d
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/MassMail/MassMailPermissionsIndex.php
@@ -0,0 +1,31 @@
+<?php
+
+namespace JsonApi\Routes\MassMail;
+
+use JsonApi\Errors\AuthorizationFailedException;
+use Psr\Http\Message\ResponseInterface as Response;
+use Psr\Http\Message\ServerRequestInterface as Request;
+use JsonApi\JsonApiController;
+
+class MassMailPermissionsIndex extends JsonApiController
+{
+    protected $allowedPagingParameters = ['offset', 'limit'];
+    protected $allowedIncludePaths = ['institute', 'allowed-degrees', 'allowed-subjects', 'allowed-institutes'];
+
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        if (!Authority::canIndexMassMailPermissions($this->getUser($request))) {
+            throw new AuthorizationFailedException();
+        }
+
+        [$offset, $limit] = $this->getOffsetAndLimit();
+
+        $total = \MassMail\MassMailPermission::countBySQL('1');
+        $permissions = \MassMail\MassMailPermission::findBySQL(
+            "JOIN `Institute` ON (`Institute`.`Institut_id` = `massmail_permissions`.`institute_id`)
+            ORDER BY `Institute`.`Name` LIMIT ?, ?",
+            [$offset, $limit]);
+
+        return $this->getPaginatedContentResponse($permissions, $total);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/MassMail/MassMailPermissionsShow.php b/lib/classes/JsonApi/Routes/MassMail/MassMailPermissionsShow.php
new file mode 100644
index 00000000000..1f91b05996e
--- /dev/null
+++ b/lib/classes/JsonApi/Routes/MassMail/MassMailPermissionsShow.php
@@ -0,0 +1,32 @@
+<?php
+
+namespace JsonApi\Routes\MassMail;
+
+use Psr\Http\Message\ServerRequestInterface as Request;
+use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\AuthorizationFailedException;
+use JsonApi\Errors\RecordNotFoundException;
+use JsonApi\JsonApiController;
+
+/**
+ * Displays settings for the given massmail permissions..
+ */
+class MassMailPermissionsShow extends JsonApiController
+{
+    /**
+     * @SuppressWarnings(PHPMD.UnusedFormalParameter)
+     */
+    public function __invoke(Request $request, Response $response, $args)
+    {
+        if (!$resource = \MassMail\MassMailPermission::find($args['id'])) {
+            throw new RecordNotFoundException();
+        }
+
+        if (!Authority::canShowMassMailPermissions($this->getUser($request), $resource)) {
+            throw new AuthorizationFailedException();
+        }
+
+
+        return $this->getContentResponse($resource);
+    }
+}
diff --git a/lib/classes/JsonApi/Routes/UserFilters/Authority.php b/lib/classes/JsonApi/Routes/UserFilters/Authority.php
index d934894bd5e..e84e4d0e146 100644
--- a/lib/classes/JsonApi/Routes/UserFilters/Authority.php
+++ b/lib/classes/JsonApi/Routes/UserFilters/Authority.php
@@ -2,17 +2,12 @@
 
 namespace JsonApi\Routes\UserFilters;
 
-use Config;
-use User;
+use Config, User, UserFilter;
 
 class Authority
 {
-    public static function canEditUserFilters(User $user): bool
+    public static function canEditUserFilters(User $user, UserFilter $filter): bool
     {
-        return $GLOBALS['perm']->have_perm('admin', $user->id)
-            || (
-                Config::get()->ALLOW_DOZENT_COURSESET_ADMIN
-                && $GLOBALS['perm']->have_perm('dozent', $user->id)
-            );
+        return $filter->canEdit($user);
     }
 }
diff --git a/lib/classes/JsonApi/Routes/UserFilters/UserFilterFieldsIndex.php b/lib/classes/JsonApi/Routes/UserFilters/UserFilterFieldsIndex.php
index ede43cf4397..d4e9efdb79d 100644
--- a/lib/classes/JsonApi/Routes/UserFilters/UserFilterFieldsIndex.php
+++ b/lib/classes/JsonApi/Routes/UserFilters/UserFilterFieldsIndex.php
@@ -4,17 +4,27 @@ namespace JsonApi\Routes\UserFilters;
 
 use Psr\Http\Message\ServerRequestInterface as Request;
 use Psr\Http\Message\ResponseInterface as Response;
+use JsonApi\Errors\BadRequestException;
 use JsonApi\JsonApiController;
 
 class UserFilterFieldsIndex extends JsonApiController
 {
+    protected $allowedFilteringParameters = ['context', 'target'];
+
     /**
      * @SuppressWarnings(PHPMD.UnusedFormalParameters)
      */
     public function __invoke(Request $request, Response $response, $args)
     {
+        $error = $this->validateFilters();
+        if ($error) {
+            throw new BadRequestException($error);
+        }
+
+        $filters = $this->getContextFilters();
+
         $fields = [];
-        foreach (\UserFilterField::getAvailableFilterFields() as $class => $name) {
+        foreach (\UserFilterField::getAvailableFilterFields($filters['context'], $filters['target']) as $class => $name) {
             // Generic datafield conditions must be handled differently.
             if (str_contains($class, '_')) {
                 [$classname, $typeparam] = explode('_', $class);
@@ -27,4 +37,30 @@ class UserFilterFieldsIndex extends JsonApiController
         return $this->getContentResponse($fields);
     }
 
+    private function validateFilters()
+    {
+        $filtering = $this->getQueryParameters()->getFilteringParameters() ?: [];
+
+        // context aka namespace filter
+        if (
+            isset($filtering['context'])
+            && !file_exists(
+                $GLOBALS['STUDIP_BASE_PATH'] . '/lib/classes/UserFilterFields/' . $filtering['context']
+            )
+        ) {
+            return 'Requested context user filters do not exist.';
+        }
+    }
+
+    private function getContextFilters()
+    {
+        $defaults = [
+            'context' => '',
+            'target' => ''
+        ];
+
+        $filtering = $this->getQueryParameters()->getFilteringParameters() ?: [];
+
+        return array_merge($defaults, $filtering);
+    }
 }
diff --git a/lib/classes/JsonApi/Routes/UserFilters/UserFiltersCreate.php b/lib/classes/JsonApi/Routes/UserFilters/UserFiltersCreate.php
index 42cd58363ba..e57dc13367c 100644
--- a/lib/classes/JsonApi/Routes/UserFilters/UserFiltersCreate.php
+++ b/lib/classes/JsonApi/Routes/UserFilters/UserFiltersCreate.php
@@ -20,16 +20,16 @@ class UserFiltersCreate extends JsonApiController
      */
     public function __invoke(Request $request, Response $response, $args)
     {
+        $filter = new \UserFilter();
+        $filter->show_user_count = true;
+
         $json = $this->validate($request);
         $user = $this->getUser($request);
 
-        if (!Authority::canEditUserFilters($user)) {
+        if (!Authority::canEditUserFilters($user, $filter)) {
             throw new AuthorizationFailedException();
         }
 
-        $filter = new \UserFilter();
-        $filter->show_user_count = true;
-
         foreach (self::arrayGet($json, 'data.attributes.filters') as $one) {
             $classname = '\\' . $one['attributes']['type'];
             $field = !empty($one['attributes']['typeparam'])
diff --git a/lib/classes/JsonApi/Routes/UserFilters/UserFiltersDelete.php b/lib/classes/JsonApi/Routes/UserFilters/UserFiltersDelete.php
index 6f2b0cb3451..e3cd53467f7 100644
--- a/lib/classes/JsonApi/Routes/UserFilters/UserFiltersDelete.php
+++ b/lib/classes/JsonApi/Routes/UserFilters/UserFiltersDelete.php
@@ -18,18 +18,18 @@ class UserFiltersDelete extends JsonApiController
      */
     public function __invoke(Request $request, Response $response, $args)
     {
-        $user = $this->getUser($request);
-
-        if (!Authority::canEditUserFilters($user)) {
-            throw new AuthorizationFailedException();
-        }
-
         $filter = new \UserFilter($args['id']);
 
         if ($filter['id'] !== $args['id']) {
             throw new RecordNotFoundException();
         }
 
+        $user = $this->getUser($request);
+
+        if (!Authority::canEditUserFilters($user, $filter)) {
+            throw new AuthorizationFailedException();
+        }
+
         $filter->delete();
 
         return $this->getCodeResponse(204);
diff --git a/lib/classes/JsonApi/Routes/UserFilters/UserFiltersUpdate.php b/lib/classes/JsonApi/Routes/UserFilters/UserFiltersUpdate.php
index 309da9b9646..f9adecc3871 100644
--- a/lib/classes/JsonApi/Routes/UserFilters/UserFiltersUpdate.php
+++ b/lib/classes/JsonApi/Routes/UserFilters/UserFiltersUpdate.php
@@ -21,21 +21,21 @@ class UserFiltersUpdate extends JsonApiController
      */
     public function __invoke(Request $request, Response $response, $args)
     {
-        $user = $this->getUser($request);
-
-        if (!Authority::canEditUserFilters($user)) {
-            throw new AuthorizationFailedException();
-        }
-
         $filter = new \UserFilter($args['id']);
 
         if ($filter['id'] !== $args['id']) {
             throw new RecordNotFoundException();
         }
 
+        $user = $this->getUser($request);
+
+        if (!Authority::canEditUserFilters($user, $filter)) {
+            throw new AuthorizationFailedException();
+        }
+
         $json = $this->validate($request);
 
-        $fields = $filter->getFields();
+        $filter->fields = [];
 
         foreach (self::arrayGet($json, 'data.attributes.filters') as $one) {
             $classname = '\\' . $one['attributes']['type'];
diff --git a/lib/classes/JsonApi/SchemaMap.php b/lib/classes/JsonApi/SchemaMap.php
index 44bfd04dc5c..50d67606f00 100644
--- a/lib/classes/JsonApi/SchemaMap.php
+++ b/lib/classes/JsonApi/SchemaMap.php
@@ -2,8 +2,6 @@
 
 namespace JsonApi;
 
-use JsonApi\Schemas\Clipboard;
-
 /**
  * @SuppressWarnings(PHPMD.CouplingBetweenObjects)
  */
@@ -37,6 +35,7 @@ class SchemaMap
             \CourseMember::class => Schemas\CourseMember::class,
             \CourseDate::class => Schemas\CourseEvent::class,
             \CourseExDate::class => Schemas\CourseEvent::class,
+            \Degree::class => Schemas\Degree::class,
             \FeedbackElement::class => Schemas\FeedbackElement::class,
             \FeedbackEntry::class => Schemas\FeedbackEntry::class,
             \JsonApi\Models\ForumCat::class => Schemas\ForumCategory::class,
@@ -44,6 +43,8 @@ class SchemaMap
             \Institute::class => Schemas\Institute::class,
             \InstituteMember::class => Schemas\InstituteMember::class,
             \LtiTool::class => Schemas\LtiTool::class,
+            \MassMail\MassMailMessage::class => Schemas\MassMailMessage::class,
+            \MassMail\MassMailPermission::class => Schemas\MassMailPermission::class,
             \Message::class => Schemas\Message::class,
             \SemClass::class => Schemas\SemClass::class,
             \Semester::class => Schemas\Semester::class,
@@ -64,7 +65,6 @@ class SchemaMap
             \FolderType::class => Schemas\Folder::class,
             \UserFilter::class => Schemas\UserFilter::class,
             \UserFilterField::class => Schemas\UserFilterField::class,
-
             \Courseware\Block::class => Schemas\Courseware\Block::class,
             \Courseware\BlockComment::class => Schemas\Courseware\BlockComment::class,
             \Courseware\BlockFeedback::class => Schemas\Courseware\BlockFeedback::class,
diff --git a/lib/classes/JsonApi/Schemas/Degree.php b/lib/classes/JsonApi/Schemas/Degree.php
new file mode 100644
index 00000000000..05a308062b9
--- /dev/null
+++ b/lib/classes/JsonApi/Schemas/Degree.php
@@ -0,0 +1,78 @@
+<?php
+
+namespace JsonApi\Schemas;
+
+use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
+use Neomerx\JsonApi\Schema\Link;
+
+class Degree extends SchemaProvider
+{
+    const TYPE = 'degrees';
+
+    const REL_AUTHOR = 'author';
+    const REL_EDITOR = 'editor';
+
+    public function getId($resource): ?string
+    {
+        return $resource->id;
+    }
+
+    public function getAttributes($resource, ContextInterface $context): iterable
+    {
+        return [
+            'name' => $resource->name,
+            'shortname' => $resource->name_kurz,
+            'description' => $resource->beschreibung,
+            'mkdate' => date('c', $resource->mkdate),
+            'chdate' => date('c', $resource->chdate)
+        ];
+    }
+
+    public function getRelationships($resource, ContextInterface $context): iterable
+    {
+        $relationships = [];
+
+        $relationships = $this->getAuthorRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_AUTHOR));
+        $relationships = $this->getEditorRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_EDITOR));
+
+        return $relationships;
+    }
+
+    private function getAuthorRelationship(array $relationships, \Degree $degree, $includeData)
+    {
+        $author = \User::find($degree->author_id);
+
+        if ($author) {
+            $relationships[self::REL_AUTHOR] = [
+                self::RELATIONSHIP_LINKS => [
+                    Link::RELATED => $this->createLinkToResource($author),
+                ]
+            ];
+
+            if ($includeData) {
+                $relationships[self::REL_AUTHOR][self::RELATIONSHIP_DATA] = $author;
+            }
+        }
+
+        return $relationships;
+    }
+
+    private function getEditorRelationship(array $relationships, \Degree $degree, $includeData)
+    {
+        $editor = \User::find($degree->editor_id);
+
+        if ($editor) {
+            $relationships[self::REL_EDITOR] = [
+                self::RELATIONSHIP_LINKS => [
+                    Link::RELATED => $this->createLinkToResource($editor),
+                ]
+            ];
+
+            if ($includeData) {
+                $relationships[self::REL_EDITOR][self::RELATIONSHIP_DATA] = $editor;
+            }
+        }
+
+        return $relationships;
+    }
+}
diff --git a/lib/classes/JsonApi/Schemas/MassMailMessage.php b/lib/classes/JsonApi/Schemas/MassMailMessage.php
new file mode 100644
index 00000000000..598b0f6a882
--- /dev/null
+++ b/lib/classes/JsonApi/Schemas/MassMailMessage.php
@@ -0,0 +1,84 @@
+<?php
+
+namespace JsonApi\Schemas;
+
+use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
+use Neomerx\JsonApi\Schema\Link;
+
+class MassMailMessage extends SchemaProvider
+{
+    const TYPE = 'mass-mail-messages';
+    const REL_FILTERS = 'filters';
+    const REL_SENDER = 'sender';
+    const REL_AUTHOR = 'author';
+    const REL_RECIPIENTS = 'recipients';
+    const REL_FOLDER = 'folder';
+    const REL_TOKENS = 'tokens';
+
+    public function getId($resource): ?string
+    {
+        return $resource->id;
+    }
+
+    public function getAttributes($resource, ContextInterface $context): iterable
+    {
+        return [
+            'send-date' => date('d.m.Y H:i', $resource->send_at_date),
+            'target' => \MassMail\MassMailMessage::getTargets()[$resource->target],
+            'config' => $resource->config,
+            'exclude-users' => $resource->exclude_users,
+            'cc' => $resource->cc,
+            'subject' => (string) $resource->subject,
+            'message' => (string) $resource->message,
+            'is-template' => (bool) $resource->is_template,
+            'locked' => (bool) $resource->locked,
+            'sent' => (bool) $resource->sent,
+            'protected' => (bool) $resource->protected,
+            'mkdate' => date('d.m.Y H:i', $resource->mkdate),
+            'chdate' => date('d.m.Y H:i', $resource->chdate)
+        ];
+    }
+
+    public function getRelationships($resource, ContextInterface $context): iterable
+    {
+        $relationships = [];
+
+        $relationships = $this->getAuthorRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_AUTHOR));
+        $relationships = $this->getSenderRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_SENDER));
+
+        return $relationships;
+    }
+
+    private function getAuthorRelationship(array $relationships, \MassMail\MassMailMessage $message, $includeData)
+    {
+        $relationships[self::REL_AUTHOR] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->createLinkToResource($message->author),
+            ]
+        ];
+
+        if ($includeData) {
+            $relationships[self::REL_AUTHOR][self::RELATIONSHIP_DATA] = $message->author;
+        }
+
+        return $relationships;
+    }
+
+    private function getSenderRelationship(array $relationships, \MassMail\MassMailMessage $message, $includeData)
+    {
+        if ($message->sender_id && $message->sender) {
+            $relationships[self::REL_SENDER] = [
+                self::RELATIONSHIP_LINKS => [
+                    Link::RELATED => $this->createLinkToResource($message->sender),
+                ]
+            ];
+
+            if ($includeData) {
+                $relationships[self::REL_SENDER][self::RELATIONSHIP_DATA] = $message->sender;
+            }
+        }
+
+        return $relationships;
+    }
+
+}
diff --git a/lib/classes/JsonApi/Schemas/MassMailPermission.php b/lib/classes/JsonApi/Schemas/MassMailPermission.php
new file mode 100644
index 00000000000..27150b4865f
--- /dev/null
+++ b/lib/classes/JsonApi/Schemas/MassMailPermission.php
@@ -0,0 +1,121 @@
+<?php
+
+namespace JsonApi\Schemas;
+
+use Neomerx\JsonApi\Contracts\Schema\ContextInterface;
+use Neomerx\JsonApi\Schema\Link;
+
+class MassMailPermission extends SchemaProvider
+{
+    const TYPE = 'mass-mail-permissions';
+    const REL_INSTITUTE = 'institute';
+    const REL_ALLOWED_DEGREES = 'allowed-degrees';
+    const REL_ALLOWED_SUBJECTS = 'allowed-subjects';
+    const REL_ALLOWED_INSTITUTES = 'allowed-institutes';
+
+    public function getId($resource): ?string
+    {
+        return $resource->id;
+    }
+
+    public function getAttributes($resource, ContextInterface $context): iterable
+    {
+        $user = $this->currentUser;
+
+        return [
+            'min-perm' => $resource->min_perm,
+            'mkdate' => date('c', $resource->mkdate),
+            'chdate' => date('c', $resource->chdate)
+        ];
+    }
+
+    public function hasResourceMeta($resource): bool
+    {
+        return true;
+    }
+
+    /**
+     * @param \MassMail\MassMailPermission $resource
+     */
+    public function getResourceMeta($resource)
+    {
+        return [
+            'allowed-degrees-count' => count($resource->allowed_degrees),
+            'allowed-subjects-count' => count($resource->allowed_subjects),
+            'allowed-institutes-count' => count($resource->allowed_institutes)
+        ];
+    }
+
+    public function getRelationships($resource, ContextInterface $context): iterable
+    {
+        $relationships = [];
+
+        $relationships = $this->getInstituteRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_INSTITUTE));
+        $relationships = $this->getAllowedDegreesRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_ALLOWED_DEGREES));
+        $relationships = $this->getAllowedSubjectsRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_ALLOWED_SUBJECTS));
+        $relationships = $this->getAllowedInstitutesRelationship($relationships, $resource, $this->shouldInclude($context, self::REL_ALLOWED_INSTITUTES));
+
+        return $relationships;
+    }
+
+    private function getInstituteRelationship(array $relationships, \MassMail\MassMailPermission $permission, $includeData)
+    {
+        $relationships[self::REL_INSTITUTE] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->createLinkToResource($permission->institute),
+            ]
+        ];
+
+        if ($includeData) {
+            $relationships[self::REL_INSTITUTE][self::RELATIONSHIP_DATA] = $permission->institute;
+        }
+
+        return $relationships;
+    }
+
+    private function getAllowedDegreesRelationship(array $relationships, \MassMail\MassMailPermission $permission, $includeData)
+    {
+
+        $relationships[self::REL_ALLOWED_DEGREES] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->getRelationshipRelatedLink($permission, self::REL_ALLOWED_DEGREES),
+            ]
+        ];
+
+        if ($includeData) {
+            $relationships[self::REL_ALLOWED_DEGREES][self::RELATIONSHIP_DATA] = $permission->allowed_degrees;
+        }
+
+        return $relationships;
+    }
+
+    private function getAllowedSubjectsRelationship(array $relationships, \MassMail\MassMailPermission $permission, $includeData)
+    {
+        $relationships[self::REL_ALLOWED_SUBJECTS] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->getRelationshipRelatedLink($permission, self::REL_ALLOWED_SUBJECTS),
+            ]
+        ];
+
+        if ($includeData) {
+            $relationships[self::REL_ALLOWED_SUBJECTS][self::RELATIONSHIP_DATA] = $permission->allowed_subjects;
+        }
+
+        return $relationships;
+    }
+
+    private function getAllowedInstitutesRelationship(array $relationships, \MassMail\MassMailPermission $permission, $includeData)
+    {
+        $relationships[self::REL_ALLOWED_INSTITUTES] = [
+            self::RELATIONSHIP_LINKS => [
+                Link::RELATED => $this->getRelationshipRelatedLink($permission, self::REL_ALLOWED_INSTITUTES),
+            ]
+        ];
+
+        if ($includeData) {
+            $relationships[self::REL_ALLOWED_INSTITUTES][self::RELATIONSHIP_DATA] = $permission->allowed_institutes;
+        }
+
+        return $relationships;
+    }
+}
diff --git a/lib/classes/admission/UserFilter.php b/lib/classes/UserFilter.php
similarity index 71%
rename from lib/classes/admission/UserFilter.php
rename to lib/classes/UserFilter.php
index fd160d68136..7745587a2b3 100644
--- a/lib/classes/admission/UserFilter.php
+++ b/lib/classes/UserFilter.php
@@ -16,7 +16,6 @@
  * @license     http://www.gnu.org/licenses/gpl-2.0.html GPL version 2
  * @category    Stud.IP
  */
-
 class UserFilter
 {
     // --- ATTRIBUTES ---
@@ -31,6 +30,10 @@ class UserFilter
      */
     public $id = '';
 
+    // Data about where this filter belongs.
+    public string $range_id = '';
+    public string $range_type = '';
+
     public $show_user_count = false;
 
     // --- OPERATIONS ---
@@ -38,12 +41,11 @@ class UserFilter
     /**
      * Standard constructor.
      *
-     * @param  String conditionId
+     * @param String conditionId
      * @return UserFilter
      */
-    public function __construct($conditionId='')
+    public function __construct($conditionId = '')
     {
-        UserFilterField::getAvailableFilterFields();
         $this->id = $conditionId;
         if ($conditionId) {
             $this->load();
@@ -56,7 +58,7 @@ class UserFilter
     /**
      * Add a new condition field.
      *
-     * @param  ConditionField fieldId
+     * @param UserFilterField fieldId
      * @return UserFilter
      */
     public function addField($field)
@@ -69,7 +71,8 @@ class UserFilter
     /**
      * Deletes the condition and all associated fields.
      */
-    public function delete() {
+    public function delete()
+    {
         // Delete condition data.
         $stmt = DBManager::get()->prepare("DELETE FROM `userfilter`
             WHERE `filter_id`=?");
@@ -83,11 +86,12 @@ class UserFilter
     /**
      * Generate a new unique ID.
      *
-     * @param  String tableName
+     * @param String tableName
      */
-    public function generateId() {
+    public function generateId()
+    {
         do {
-            $newid = md5(uniqid(get_class($this).microtime(), true));
+            $newid = md5(uniqid(get_class($this) . microtime(), true));
             $id = DBManager::get()->fetchColumn("SELECT `filter_id`
                 FROM `userfilter` WHERE `filter_id`=?", [$newid]);
         } while ($id);
@@ -102,8 +106,8 @@ class UserFilter
      */
     public function getFields()
     {
-        uasort($this->fields, function($a, $b) {
-            return $a->sortOrder -  $b->sortOrder;
+        uasort($this->fields, function ($a, $b) {
+            return $a->sortOrder - $b->sortOrder;
         });
         return $this->fields;
     }
@@ -123,7 +127,8 @@ class UserFilter
      *
      * @return Array
      */
-    public function getUsers() {
+    public function getUsers()
+    {
         $users = null;
         foreach ($this->fields as $field) {
             // Check if restrictions for the field value must be taken into consideration.
@@ -142,7 +147,7 @@ class UserFilter
             }
             $users = isset($users) ? array_intersect($users, $field->getUsers($restrictions)) : $field->getUsers($restrictions);
         }
-        return (array) $users;
+        return (array)$users;
     }
 
     /**
@@ -152,7 +157,8 @@ class UserFilter
      * @param String $className the type to check for
      * @return UserFilterField Return the found field or null if not applicable.
      */
-    public function hasField($className) {
+    public function hasField($className)
+    {
         foreach ($this->fields as $field) {
             if ($field instanceof $className) {
                 return $field;
@@ -182,13 +188,16 @@ class UserFilter
     /**
      * Helper function for loading data from DB.
      */
-    public function load() {
+    public function load()
+    {
         // Load basic condition data.
         $stmt = DBManager::get()->prepare(
             "SELECT * FROM `userfilter` WHERE `filter_id`=? LIMIT 1");
         $stmt->execute([$this->id]);
         if ($data = $stmt->fetch(PDO::FETCH_ASSOC)) {
             $this->id = $data['filter_id'];
+            $this->range_id = $data['range_id'];
+            $this->range_type = $data['range_type'];
             // Load the associated condition fields.
             $stmt = DBManager::get()->prepare(
                 "SELECT `field_id`, `type` FROM `userfilter_fields`
@@ -201,16 +210,16 @@ class UserFilter
                  * been removed since saving data to DB.
                  */
                 //try {
-                    $chunks = explode('_', $data['type']);
-                    $type = $chunks[0];
-                    $param = $chunks[1] ?? null;
-                    if ($param) {
-                        $field = new $type($param, $data['field_id']);
-                    } else {
-                        $field = new $type($data['field_id']);
-                    }
+                $chunks = explode('_', $data['type']);
+                $type = $chunks[0];
+                $param = $chunks[1] ?? null;
+                if ($param) {
+                    $field = new $type($param, $data['field_id']);
+                } else {
+                    $field = new $type($data['field_id']);
+                }
 
-                    $this->fields[$field->getId()] = $field;
+                $this->fields[$field->getId()] = $field;
                 //} catch (Exception $e) {}
             }
         }
@@ -219,7 +228,7 @@ class UserFilter
     /**
      * Removes the field with the given ID from the condition fields.
      *
-     * @param  String fieldId
+     * @param String fieldId
      * @return UserFilter
      */
     public function removeField($fieldId)
@@ -231,7 +240,8 @@ class UserFilter
     /**
      * Stores data to DB.
      */
-    public function store() {
+    public function store()
+    {
         // Generate new ID if condition entry doesn't exist in DB yet.
         if (!$this->id) {
             $this->id = $this->generateId();
@@ -239,34 +249,36 @@ class UserFilter
 
         // Store condition data.
         $stmt = DBManager::get()->prepare("INSERT INTO `userfilter`
-            (`filter_id`, `mkdate`, `chdate`)
-            VALUES (?, ?, ?)
-            ON DUPLICATE KEY UPDATE `chdate`=VALUES(`chdate`)");
-        $stmt->execute([$this->id, time(), time()]);
+            (`filter_id`, `range_id`, `range_type`, `mkdate`, `chdate`)
+            VALUES (?, ?, ?, ?, ?)
+            ON DUPLICATE KEY UPDATE `chdate` = VALUES(`chdate`), `range_type` = VALUES(`range_type`), `range_id` = VALUES(`range_id`)");
+        $stmt->execute([$this->id, $this->range_id, $this->range_type, time(), time()]);
         // Delete removed condition fields from DB.
         DBManager::get()->exec("DELETE FROM `userfilter_fields`
-            WHERE `filter_id`='".$this->id."' AND `field_id` NOT IN ('".
-            implode("', '", array_keys($this->fields))."')");
+            WHERE `filter_id`='" . $this->id . "' AND `field_id` NOT IN ('" .
+            implode("', '", array_keys($this->fields)) . "')");
         // Store all fields.
         foreach ($this->fields as $field) {
             $field->store($this->id);
         }
     }
 
-    public function toString() {
+    public function toString()
+    {
         $tpl = $GLOBALS['template_factory']->open('userfilter/display');
         $tpl->set_attribute('filter', $this);
         return $tpl->render();
     }
 
-    public function __toString() {
+    public function __toString()
+    {
         return $this->toString();
     }
 
     public function __clone()
     {
         $this->id = md5(uniqid(get_class($this)));
-        $cloned_fields= [];
+        $cloned_fields = [];
         foreach ($this->fields as $field) {
             $dolly = clone $field;
             $dolly->conditionId = $this->id;
@@ -275,6 +287,34 @@ class UserFilter
         $this->fields = $cloned_fields;
     }
 
+    /**
+     * Checks whether the given user can edit this filter.
+     * @return bool
+     */
+    public function canEdit(User $user): bool
+    {
+        // This is a new object, we can always create that as it has no other connection to the system or database.
+        if (!$this->range_type || !$this->range_id) {
+            return true;
+        }
+
+        // Check for an existing object, using range_type and tange_id.
+        $range = new $this->range_type($this->range_id);
+        return $range->canEditFilter($user, $this);
+    }
+
+    /**
+     * Sets the range this UserFilter belongs to.
+     * @param string $type
+     * @param string|int $id
+     * @return void
+     */
+    public function setRange(string $type, string|int $id): void
+    {
+        $this->range_type = $type;
+        $this->range_id = $id;
+    }
+
 } /* end of class UserFilter */
 
 ?>
diff --git a/lib/classes/admission/UserFilterField.php b/lib/classes/UserFilterField.php
similarity index 79%
rename from lib/classes/admission/UserFilterField.php
rename to lib/classes/UserFilterField.php
index 2a3480700d4..d99748928af 100644
--- a/lib/classes/admission/UserFilterField.php
+++ b/lib/classes/UserFilterField.php
@@ -16,7 +16,6 @@
  * @license     http://www.gnu.org/licenses/gpl-2.0.html GPL version 2
  * @category    Stud.IP
  */
-
 class UserFilterField
 {
     // --- ATTRIBUTES ---
@@ -55,7 +54,7 @@ class UserFilterField
      * Provide some kind of sort order for filter fields. By default,
      * all subclasses without an explicitly given order will be sorted at the end.
      */
-    public $sortOrder = 99;
+    public static $sortOrder = 99;
 
     public static $isParameterized = false;
 
@@ -80,6 +79,24 @@ class UserFilterField
 
     }
 
+    /**
+     * Which targets are allowed for this filter field?
+     * An empty array means: no restrictions
+     * @return array
+     */
+    public static function getTargets()
+    {
+        return [];
+    }
+
+    /**
+     * Indicates whether this filter field is active.
+     * @return true
+     */
+    public static function isActive()
+    {
+        return true;
+    }
 
     /**
      * Standard constructor.
@@ -100,8 +117,8 @@ class UserFilterField
             } else {
                 // Get all available values from database.
                 $stmt = DBManager::get()->query(
-                        "SELECT DISTINCT `" . $this->valuesDbIdField . "`, `" . $this->valuesDbNameField . "` " .
-                        "FROM `" . $this->valuesDbTable . "` ORDER BY `" . $this->valuesDbNameField . "` ASC");
+                    "SELECT DISTINCT `" . $this->valuesDbIdField . "`, `" . $this->valuesDbNameField . "` " .
+                    "FROM `" . $this->valuesDbTable . "` ORDER BY `" . $this->valuesDbNameField . "` ASC");
                 while ($current = $stmt->fetch(PDO::FETCH_ASSOC)) {
                     $this->validValues[$current[$this->valuesDbIdField]] = $current[$this->valuesDbNameField];
                 }
@@ -121,7 +138,7 @@ class UserFilterField
      * value is compared to the currently selected value by using the
      * currently selected compare operator.
      *
-     * @param  Array values
+     * @param Array values
      * @return Boolean
      */
     public function checkValue($values)
@@ -177,12 +194,12 @@ class UserFilterField
     /**
      * Generate a new unique ID.
      *
-     * @param  String tableName
+     * @param String tableName
      */
     public function generateId()
     {
         do {
-            $newid = md5(uniqid(get_class($this).microtime(), true));
+            $newid = md5(uniqid(get_class($this) . microtime(), true));
             $id = DBManager::get()->fetchColumn("SELECT `field_id`
                 FROM `userfilter_fields` WHERE `field_id`=?", [$newid]);
         } while ($id);
@@ -192,23 +209,46 @@ class UserFilterField
     /**
      * Reads all available UserFilterField subclasses and loads their definitions.
      */
-    public static function getAvailableFilterFields()
+    public static function getAvailableFilterFields(string $context = '', string $target = '')
     {
         if (self::$available_filter_fields === null) {
             $fields = [];
             $i = new FileSystemIterator(
-                $GLOBALS['STUDIP_BASE_PATH'] . '/lib/classes/admission/userfilter',
+                $GLOBALS['STUDIP_BASE_PATH'] . '/lib/classes/UserFilterFields' . ($context !== '' ? '/' . $context : ''),
                 FileSystemIterator::SKIP_DOTS
             );
 
             foreach ($i as $class) {
-                require_once $class;
+                if ($class->isFile()) {
+                    require_once $class;
+                }
             }
 
+            // Get all classes in given context.
             $classes = array_filter(
                 get_declared_classes(),
-                fn($c) => is_subclass_of($c, UserFilterField::class)
+                function ($c) use ($context) {
+                    $reflection_class = new \ReflectionClass($c);
+                    $namespace = $reflection_class->getNamespaceName();
+                    return is_subclass_of($c, UserFilterField::class)
+                        && $namespace === 'UserFilterFields' . ($context !== '' ? '\\' . $context : '')
+                        && $c::isActive();
+                }
             );
+
+            usort($classes, fn ($a, $b) => $a::$sortOrder - $b::$sortOrder);
+
+            // If a target is given, return only matching classes
+            if ($target !== '') {
+                $classes = array_filter(
+                    $classes,
+                    function ($c) use ($target) {
+                        $targets = $c::getTargets();
+                        return count($targets) === 0 || in_array($target, $targets);
+                    }
+                );
+            }
+
             foreach ($classes as $class) {
                 if ($class::$isParameterized) {
                     $fields = array_merge($fields, $class::getParameterizedTypes());
@@ -217,7 +257,6 @@ class UserFilterField
                     $fields[$class] = $filter->getName();
                 }
             }
-            asort($fields);
             self::$available_filter_fields = $fields;
         }
         return self::$available_filter_fields;
@@ -281,10 +320,10 @@ class UserFilterField
         $db = DBManager::get();
         $users = [];
         // Standard query getting the values without respecting other values.
-        $select = "SELECT DISTINCT `".$this->userDataDbTable."`.`user_id` ";
-        $from = "FROM `".$this->userDataDbTable."` ";
-        $where = "WHERE `".$this->userDataDbTable."`.`".$this->userDataDbField.
-            "`".$this->compareOperator."?";
+        $select = "SELECT DISTINCT `" . $this->userDataDbTable . "`.`user_id` ";
+        $from = "FROM `" . $this->userDataDbTable . "` ";
+        $where = "WHERE `" . $this->userDataDbTable . "`.`" . $this->userDataDbField .
+            "`" . $this->compareOperator . "?";
         $parameters = [$this->value];
         $joinedTables = [
             $this->userDataDbTable => true
@@ -296,20 +335,20 @@ class UserFilterField
                 // Do we need to join in another table?
                 if (!$joinedTables[$restriction['table']]) {
                     $joinedTables[$restriction['table']] = true;
-                    $from .= " INNER JOIN `".$restriction['table']."` ON (`".
-                        $this->userDataDbTable."`.`".
-                        $this->relations[$otherField]['local_field']."`=`".
-                        $restriction['table']."`.`".
-                        $this->relations[$otherField]['foreign_field']."`)";
+                    $from .= " INNER JOIN `" . $restriction['table'] . "` ON (`" .
+                        $this->userDataDbTable . "`.`" .
+                        $this->relations[$otherField]['local_field'] . "`=`" .
+                        $restriction['table'] . "`.`" .
+                        $this->relations[$otherField]['foreign_field'] . "`)";
                 }
                 // Expand WHERE statement with the value from restriction.
-                $where .= " AND `".$restriction['table']."`.`".
-                    $restriction['field']."`".$restriction['compare']."?";
+                $where .= " AND `" . $restriction['table'] . "`.`" .
+                    $restriction['field'] . "`" . $restriction['compare'] . "?";
                 $parameters[] = $restriction['value'];
             }
         }
         // Get all the users that fulfill the condition.
-        $stmt = $db->prepare($select.$from.$where);
+        $stmt = $db->prepare($select . $from . $where);
         $stmt->execute($parameters);
         while ($current = $stmt->fetch(PDO::FETCH_ASSOC)) {
             $users[] = $current['user_id'];
@@ -323,15 +362,15 @@ class UserFilterField
      * for the user. These can then be compared with the required degrees
      * whether they fit.
      *
-     * @param  String $userId User to check.
-     * @param  array $additional conditions that are required for check.
+     * @param String $userId User to check.
+     * @param array $additional conditions that are required for check.
      * @return array The value(s) for this user.
      */
     public function getUserValues($userId, $additional = null)
     {
         $result = [];
-        $query = "SELECT DISTINCT `".$this->userDataDbField."` ".
-            "FROM `".$this->userDataDbTable."` ".
+        $query = "SELECT DISTINCT `" . $this->userDataDbField . "` " .
+            "FROM `" . $this->userDataDbTable . "` " .
             "WHERE `user_id`=?";
         $parameters = [$userId];
         // Additional requirements given...
@@ -342,7 +381,7 @@ class UserFilterField
 
             foreach ($additional as $a_condition) {
                 if ($a_condition->id != $this->id && $this->userDataDbTable == $a_condition->userDataDbTable &&
-                        !in_array($a_condition->userDataDbField, $usedFields)) {
+                    !in_array($a_condition->userDataDbField, $usedFields)) {
                     $query .= " AND `" . $a_condition->userDataDbField . "` " . $a_condition->compareOperator . "?";
                     $parameters[] = $a_condition->value;
                 }
@@ -406,7 +445,7 @@ class UserFilterField
     /**
      * Sets a new selected compare operator
      *
-     * @param  String newOperator
+     * @param String newOperator
      * @return UserFilterField
      */
     public function setCompareOperator($newOperator)
@@ -422,7 +461,7 @@ class UserFilterField
     /**
      * Connects the current field to a UserFilter.
      *
-     * @param  String $id ID of a UserFilter object.
+     * @param String $id ID of a UserFilter object.
      * @return UserFilterField
      */
     public function setConditionId($id)
@@ -434,7 +473,7 @@ class UserFilterField
     /**
      * Sets a new selected value.
      *
-     * @param  String newValue
+     * @param String newValue
      * @return UserFilterField
      */
     public function setValue($newValue)
@@ -450,7 +489,7 @@ class UserFilterField
     /**
      * Stores data to DB.
      *
-     * @param  String conditionId The condition this field belongs to.
+     * @param String conditionId The condition this field belongs to.
      */
     public function store()
     {
diff --git a/lib/classes/admission/userfilter/DatafieldCondition.php b/lib/classes/UserFilterFields/DatafieldCondition.php
similarity index 81%
rename from lib/classes/admission/userfilter/DatafieldCondition.php
rename to lib/classes/UserFilterFields/DatafieldCondition.php
index 1bc93e8b775..9e7a2c334eb 100644
--- a/lib/classes/admission/userfilter/DatafieldCondition.php
+++ b/lib/classes/UserFilterFields/DatafieldCondition.php
@@ -12,22 +12,24 @@
  * @license     http://www.gnu.org/licenses/gpl-2.0.html GPL version 2
  * @category    Stud.IP
  */
-class DatafieldCondition extends UserFilterField
+namespace UserFilterFields;
+
+class DatafieldCondition extends \UserFilterField
 {
     public static $isParameterized = true;
 
     public $datafield_id, $null_yields, $datafield_name;
 
-    public $sortOrder = 6;
+    public static $sortOrder = 6;
 
     public static function getParameterizedTypes()
     {
         $ret = [];
         try {
-            foreach (DataField::findBySQL("object_type='user' AND (object_class & (1|2|4|8) OR object_class IS NULL) AND is_userfilter = 1 ORDER BY priority") as $df) {
+            foreach (\DataField::findBySQL("object_type='user' AND (object_class & (1|2|4|8) OR object_class IS NULL) AND is_userfilter = 1 ORDER BY priority") as $df) {
                 $ret[__CLASS__ . '_' . $df->id] = utf8_encode(chr(160)) . _("Datenfeld") . ': ' . $df->name;
             }
-        } catch (PDOException $e) {} //migration 128 chokes on this...
+        } catch (\PDOException $e) {} //migration 128 chokes on this...
         return $ret;
     }
     /**
@@ -49,26 +51,26 @@ class DatafieldCondition extends UserFilterField
             $this->datafield_id = $typeparam;
         }
 
-        $df = DataField::find($this->datafield_id);
+        $df = \DataField::find($this->datafield_id);
         if ($df) {
             $this->datafield_name = $df->name;
         } else {
-            throw new UnexpectedValueException('datafield not found, id: ' . $typeparam);
+            throw new \UnexpectedValueException('datafield not found, id: ' . $typeparam);
         }
-        $typed_df = DataFieldEntry::createDataFieldEntry($df);
-        if ($typed_df instanceof DataFieldBoolEntry) {
+        $typed_df = \DataFieldEntry::createDataFieldEntry($df);
+        if ($typed_df instanceof \DataFieldBoolEntry) {
             $this->validValues = [1 => _('Ja'), 0 => _('Nein')];
             unset($this->validCompareOperators['>=']);
             unset($this->validCompareOperators['<=']);
             unset($this->validCompareOperators['!=']);
             $this->null_yields = 0;
-        } else if ($typed_df instanceof DataFieldSelectboxEntry) {
+        } else if ($typed_df instanceof \DataFieldSelectboxEntry) {
             list($valid_values, $is_assoc) = $typed_df->getParameters();
             if (!$is_assoc) {
                 $valid_values = array_combine($valid_values, $valid_values);
             }
             $this->validValues = $valid_values;
-            $this->null_yields = $typed_df instanceof DataFieldSelectboxMultipleEntry ? '' : key($valid_values);
+            $this->null_yields = $typed_df instanceof \DataFieldSelectboxMultipleEntry ? '' : key($valid_values);
         } else {
             $this->null_yields = '';
         }
@@ -87,7 +89,7 @@ class DatafieldCondition extends UserFilterField
 
     public function getUsers($restrictions = [])
     {
-        $db = DBManager::get();
+        $db = \DBManager::get();
         // Standard query getting the values without respecting other values.
         $select = "SELECT user_id FROM
                     auth_user_md5 LEFT JOIN
@@ -107,7 +109,7 @@ class DatafieldCondition extends UserFilterField
      */
     public function getUserValues($userId, $additional = null)
     {
-        $result = DBManager::get()->fetchColumn(
+        $result = \DBManager::get()->fetchColumn(
             "SELECT content FROM datafields_entries
             WHERE datafield_id = ? AND range_id = ?", [$this->datafield_id, $userId]);
         return [$result === null || $result === false ? $this->null_yields : $result];
@@ -118,10 +120,10 @@ class DatafieldCondition extends UserFilterField
      */
     public function load()
     {
-        $stmt = DBManager::get()->prepare(
+        $stmt = \DBManager::get()->prepare(
             "SELECT * FROM `userfilter_fields` WHERE `field_id`=? LIMIT 1");
         $stmt->execute([$this->id]);
-        if ($data = $stmt->fetch(PDO::FETCH_ASSOC)) {
+        if ($data = $stmt->fetch(\PDO::FETCH_ASSOC)) {
             $this->conditionId = $data['filter_id'];
             $this->value = $data['value'];
             $this->compareOperator = $data['compare_op'];
@@ -152,7 +154,7 @@ class DatafieldCondition extends UserFilterField
             $this->id = $this->generateId();
         }
         // Store field data.
-        $stmt = DBManager::get()->prepare("INSERT INTO `userfilter_fields`
+        $stmt = \DBManager::get()->prepare("INSERT INTO `userfilter_fields`
             (`field_id`, `filter_id`, `type`, `value`, `compare_op`,
             `mkdate`, `chdate`)  VALUES (?, ?, ?, ?, ?, ?, ?)
             ON DUPLICATE KEY UPDATE `filter_id`=VALUES(`filter_id`),
diff --git a/lib/classes/admission/userfilter/DegreeCondition.php b/lib/classes/UserFilterFields/DegreeCondition.php
similarity index 92%
rename from lib/classes/admission/userfilter/DegreeCondition.php
rename to lib/classes/UserFilterFields/DegreeCondition.php
index 61ce456d0c4..6b26fb02e6a 100644
--- a/lib/classes/admission/userfilter/DegreeCondition.php
+++ b/lib/classes/UserFilterFields/DegreeCondition.php
@@ -1,4 +1,5 @@
 <?php
+
 /**
  * DegreeCondition.php
  *
@@ -13,7 +14,9 @@
  * @license     http://www.gnu.org/licenses/gpl-2.0.html GPL version 2
  * @category    Stud.IP
  */
-class DegreeCondition extends UserFilterField
+namespace UserFilterFields;
+
+class DegreeCondition extends \UserFilterField
 {
     // --- ATTRIBUTES ---
     public $valuesDbTable = 'abschluss';
@@ -22,7 +25,7 @@ class DegreeCondition extends UserFilterField
     public $userDataDbTable = 'user_studiengang';
     public $userDataDbField = 'abschluss_id';
 
-    public $sortOrder = 1;
+    public static $sortOrder = 1;
 
     /**
      * @see UserFilterField::__construct
diff --git a/lib/classes/UserFilterFields/DomainCondition.php b/lib/classes/UserFilterFields/DomainCondition.php
new file mode 100644
index 00000000000..125a16b2bc9
--- /dev/null
+++ b/lib/classes/UserFilterFields/DomainCondition.php
@@ -0,0 +1,45 @@
+<?php
+
+/**
+ * DomainCondition.php
+ *
+ * All conditions concerning the user domain in Stud.IP can be specified here.
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ *
+ * @author      Thomas Hackl <thomas.hackl@uni-passau.de>
+ * @license     http://www.gnu.org/licenses/gpl-2.0.html GPL version 2
+ * @category    Stud.IP
+ */
+namespace UserFilterFields;
+
+class DomainCondition extends \UserFilterField
+{
+    // --- ATTRIBUTES ---
+    public $valuesDbTable = 'userdomains';
+    public $valuesDbIdField = 'userdomain_id';
+    public $valuesDbNameField = 'name';
+    public $userDataDbTable = 'user_userdomains';
+    public $userDataDbField = 'userdomain_id';
+
+    public static $sortOrder = 8;
+
+    public static function isActive()
+    {
+        return \UserDomain::countBySQL("1") > 0;
+    }
+
+    /**
+     * Get this field's display name.
+     *
+     * @return String
+     */
+    public function getName()
+    {
+        return _('Domäne');
+    }
+
+}
diff --git a/lib/classes/UserFilterFields/MassMail/MassMailDegreeFilter.php b/lib/classes/UserFilterFields/MassMail/MassMailDegreeFilter.php
new file mode 100644
index 00000000000..71640d49b08
--- /dev/null
+++ b/lib/classes/UserFilterFields/MassMail/MassMailDegreeFilter.php
@@ -0,0 +1,126 @@
+<?php
+
+namespace UserFilterFields\MassMail;
+
+use UserFilterFields\DegreeCondition;
+use MassMail\MassMailPermission;
+use User;
+use DBManager;
+use PDO;
+
+class MassMailDegreeFilter extends DegreeCondition
+{
+    /**
+     * @see \UserFilterField::getTargets()
+     */
+    public static function getTargets()
+    {
+        return ['students'];
+    }
+
+    public function __construct($fieldId = '')
+    {
+        parent::__construct($fieldId);
+
+        if (!MassMailPermission::has(User::findCurrent()->id, true)) {
+            $this->validValues = [];
+
+            $permission = MassMailPermission::getForUser(User::findCurrent(), true);
+
+            foreach ($permission['allowed_degrees'] as [$id, $name]) {
+                $this->validValues[$id] = (string) $name;
+            }
+        }
+    }
+
+    public function getUsers($restrictions = [])
+    {
+        $users = [];
+
+        if (MassMailPermission::has(User::findCurrent()->id, true)) {
+            $users = parent::getUsers($restrictions);
+        } else if (count($this->validValues) > 0) {
+            // Standard query getting the values without respecting other values.
+            $select = "SELECT DISTINCT `" . $this->userDataDbTable . "`.`user_id` ";
+            $from = "FROM `" . $this->userDataDbTable . "` ";
+            $where = "WHERE `" . $this->userDataDbTable . "`.`" . $this->userDataDbField .
+                "`" . $this->compareOperator . "?";
+            $parameters = [$this->value];
+            $joinedTables = [
+                $this->userDataDbTable => true
+            ];
+            // Check if there are restrictions given.
+            foreach ($restrictions as $otherField => $restriction) {
+                // We only take the value into consideration if it represents a valid restriction.
+                if ($this->relations[$otherField]) {
+                    // Do we need to join in another table?
+                    if (!$joinedTables[$restriction['table']]) {
+                        $joinedTables[$restriction['table']] = true;
+                        $from .= " INNER JOIN `" . $restriction['table'] . "` ON (`" .
+                            $this->userDataDbTable . "`.`" .
+                            $this->relations[$otherField]['local_field'] . "`=`" .
+                            $restriction['table'] . "`.`" .
+                            $this->relations[$otherField]['foreign_field'] . "`)";
+                    }
+                    // Expand WHERE statement with the value from restriction.
+                    $where .= " AND `" . $restriction['table'] . "`.`" .
+                        $restriction['field'] . "`" . $restriction['compare'] . "?";
+                    $parameters[] = $restriction['value'];
+                }
+            }
+
+            $where .= " AND `" . $this->userDataDbTable . "`.`" . $this->userDataDbField . "` IN (?)";
+            $parameters[] = array_keys($this->validValues);
+
+            // Get all the users that fulfill the condition.
+            $users = \DBManager::get()->fetchFirst($select . $from . $where, $parameters);
+        }
+
+        return $users;
+    }
+
+    /**
+     * Gets the value for the given user that is relevant for this
+     * condition field. Here, this method looks up the study degree(s)
+     * for the user. These can then be compared with the required degrees
+     * whether they fit.
+     *
+     * @param  String $userId User to check.
+     * @param  array $additional conditions that are required for check.
+     * @return array The value(s) for this user.
+     */
+    public function getUserValues($userId, $additional = null)
+    {
+        if (MassMailPermission::has(User::findCurrent()->id, true)) {
+            $result = parent::getUserValues($userId, $additional);
+        } else {
+            $result = [];
+            $query = "SELECT DISTINCT `" . $this->userDataDbField . "` " .
+                "FROM `" . $this->userDataDbTable . "` " .
+                "WHERE `user_id`=?";
+            $parameters = [$userId];
+            // Additional requirements given...
+            if (is_array($additional)) {
+
+                // Don't use the same database field twice as this can only get ugly.
+                $usedFields = [$this->userDataDbField];
+
+                foreach ($additional as $a_condition) {
+                    if ($a_condition->id != $this->id && $this->userDataDbTable == $a_condition->userDataDbTable &&
+                        !in_array($a_condition->userDataDbField, $usedFields)) {
+                        $query .= " AND `" . $a_condition->userDataDbField . "` " . $a_condition->compareOperator . "?";
+                        $parameters[] = $a_condition->value;
+                    }
+                }
+            }
+            // Get semester of study for user.
+            $stmt = DBManager::get()->prepare($query);
+            $stmt->execute($parameters);
+            while ($current = $stmt->fetch(PDO::FETCH_ASSOC)) {
+                $result[] = $current[$this->userDataDbField];
+            }
+        }
+        return $result;
+    }
+
+}
diff --git a/lib/classes/UserFilterFields/MassMail/MassMailDomainFilter.php b/lib/classes/UserFilterFields/MassMail/MassMailDomainFilter.php
new file mode 100644
index 00000000000..7d7a9225fff
--- /dev/null
+++ b/lib/classes/UserFilterFields/MassMail/MassMailDomainFilter.php
@@ -0,0 +1,75 @@
+<?php
+
+/**
+ * DomainCondition.php
+ *
+ * All conditions concerning the user domain in Stud.IP can be specified here.
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ *
+ * @author      Thomas Hackl <thomas.hackl@uni-passau.de>
+ * @license     http://www.gnu.org/licenses/gpl-2.0.html GPL version 2
+ * @category    Stud.IP
+ */
+namespace UserFilterFields\MassMail;
+
+use MassMail\MassMailPermission;
+use UserFilterFields\DomainCondition;
+use DBManager;
+use User;
+
+class MassMailDomainFilter extends DomainCondition
+{
+
+    public string $target = '';
+
+    /**
+     * Gets all users belonging to given domain.
+     *
+     * @return array All users that are affected by the current condition
+     * field.
+     */
+    public function getUsers($restrictions = [])
+    {
+        $users = [];
+        if (MassMailPermission::has(User::findCurrent()->id, true)) {
+            $users = parent::getUsers($restrictions);
+        } else if (MassMailPermission::has(User::findCurrent()->id)) {
+
+            $allowed = MassMailPermission::getForUser(User::findCurrent());
+
+            switch ($this->target) {
+                case 'employees':
+                    $sql = "SELECT DISTINCT `" . $this->userDataDbTable . "`.`user_id` FROM `" . $this->userDataDbTable
+                        . "`JOIN `user_inst` USING (`user_id`) ";
+                    $where = "WHERE `" . $this->userDataDbTable . "`.`" . $this->userDataDbField . "`" . $this->compareOperator
+                        . ":value AND `user_inst`.`Institut_id` IN (:institutes) AND `user_inst`.`inst_perms` IN (:perms)";
+                    $parameters = [
+                        'value' => $this->value,
+                        'institutes' => $allowed['allowed_institutes'],
+                        'perms' => ['autor', 'tutor', 'dozent', 'admin']
+                    ];
+                    break;
+                case 'students':
+                default:
+                    $sql = "SELECT DISTINCT `" . $this->userDataDbTable . "`.`user_id` FROM `" . $this->userDataDbTable
+                        . "`JOIN `user_studiengang` USING (`user_id`) ";
+                    $where = "WHERE `" . $this->userDataDbTable . "`.`" . $this->userDataDbField . "`" . $this->compareOperator
+                        . ":value AND `user_studiengang`.`abschluss_id` IN (:degrees)
+                        AND `user_studiengang`.`fach_id` IN (:subjects)";
+                    $parameters = [
+                        'value' => $this->value,
+                        'degrees' => $allowed['allowed_degrees'],
+                        'subjects' => $allowed['allowed_subjects']
+                    ];
+                    break;
+            }
+            $users = DBManager::get()->fetchFirst($sql . $where, $parameters);
+        }
+
+        return $users;
+    }
+}
diff --git a/lib/classes/UserFilterFields/MassMail/MassMailGenderFilter.php b/lib/classes/UserFilterFields/MassMail/MassMailGenderFilter.php
new file mode 100644
index 00000000000..089fa95c255
--- /dev/null
+++ b/lib/classes/UserFilterFields/MassMail/MassMailGenderFilter.php
@@ -0,0 +1,74 @@
+<?php
+
+namespace UserFilterFields\MassMail;
+
+use UserFilterField;
+use MassMail\MassMailPermission;
+use User;
+use DBManager;
+
+class MassMailGenderFilter extends UserFilterField
+{
+    public $userDataDbField = 'geschlecht';
+    public $userDataDbTable = 'user_info';
+
+    public static $sortOrder = 8;
+
+    public $target = '';
+
+    public function __construct($fieldId = '')
+    {
+        parent::__construct($fieldId);
+
+        $this->validCompareOperators = [
+            '='   => _('ist'),
+            '!=' => _('ist nicht'),
+        ];
+
+        $this->validValues = [
+            0 => _('unbekannt'),
+            1 => _('männlich'),
+            2 => _('weiblich'),
+            3 => _('divers')
+        ];
+    }
+
+    public function getName()
+    {
+        return _('Geschlecht');
+    }
+
+    /**
+     * Gets all users with given gender.
+     *
+     * @return array All users that are affected by the current condition
+     * field.
+     */
+    public function getUsers($restrictions = [])
+    {
+        $users = [];
+        if (MassMailPermission::has(User::findCurrent()->id, true)) {
+            $users = DBManager::get()->fetchFirst("SELECT DISTINCT `user_id` " .
+                "FROM `" . $this->userDataDbTable . "` " .
+                "WHERE `" . $this->userDataDbField . "`" . $this->compareOperator .
+                "?", [$this->value]);
+        } else if (MassMailPermission::has(User::findCurrent()->id)) {
+
+            $allowed = MassMailPermission::getForUser(User::findCurrent());
+
+            $sql = "SELECT DISTINCT `" . $this->userDataDbTable . "`.`user_id` FROM `" . $this->userDataDbTable
+                . "`JOIN `user_inst` USING (`user_id`) ";
+            $where = "WHERE `" . $this->userDataDbTable . "`.`" . $this->userDataDbField . "`" . $this->compareOperator
+                . ":value AND `user_inst`.`Institut_id` IN (:institutes) AND `user_inst`.`inst_perms` IN (:perms)";
+            $parameters = [
+                'value' => $this->value,
+                'institutes' => $allowed['allowed_institutes'],
+                'perms' => ['autor', 'tutor', 'dozent', 'admin']
+            ];
+
+            $users = DBManager::get()->fetchFirst($sql.$where, $parameters);
+        }
+
+        return $users;
+    }
+}
diff --git a/lib/classes/UserFilterFields/MassMail/MassMailInstituteFilter.php b/lib/classes/UserFilterFields/MassMail/MassMailInstituteFilter.php
new file mode 100644
index 00000000000..4b4d49db244
--- /dev/null
+++ b/lib/classes/UserFilterFields/MassMail/MassMailInstituteFilter.php
@@ -0,0 +1,140 @@
+<?php
+
+namespace UserFilterFields\MassMail;
+
+use UserFilterField;
+use MassMail\MassMailPermission;
+use User;
+use DBManager;
+
+class MassMailInstituteFilter extends UserFilterField
+{
+    public $valuesDbTable = 'Institute';
+    public $valuesDbIdField = 'Institut_id';
+    public $valuesDbNameField = 'Name';
+    public $userDataDbTable = 'user_inst';
+    public $userDataDbField = 'Institut_id';
+
+    public static $sortOrder = 9;
+
+    public static function getTargets()
+    {
+        return ['employees'];
+    }
+
+    public function __construct($fieldId = '')
+    {
+        parent::__construct($fieldId);
+
+        $this->validCompareOperators = [
+            '='   => _('ist'),
+            '!=' => _('ist nicht'),
+        ];
+
+        if (MassMailPermission::has(User::findCurrent()->id, true)) {
+            // Get all available institutes from database, grouped by faculty.
+            $faculties = DBManager::get()->fetchAll(
+                "SELECT `Institut_id`, `Name` FROM `Institute`
+                         WHERE `fakultaets_id` = `Institut_id` ORDER BY `Name`"
+            );
+            foreach ($faculties as $f) {
+                $this->validValues[$f[$this->valuesDbIdField]] = $f[$this->valuesDbNameField];
+                $this->validValues[$f[$this->valuesDbIdField].'_children'] =
+                    sprintf(_('%s und Untereinrichtungen'),
+                        $f[$this->valuesDbNameField]);
+                $institutes = DBManager::get()->fetchAll(
+                    "SELECT `Institut_id`, `Name` FROM `Institute`
+                             WHERE `fakultaets_id` = :fak AND `Institut_id` != :fak ORDER BY `Name`",
+                    ['fak' => $f[$this->valuesDbIdField]]
+                );
+                foreach ($institutes as $i) {
+                    $this->validValues[$i[$this->valuesDbIdField]] = $i[$this->valuesDbNameField];
+                }
+            }
+        } else if (MassMailPermission::has(User::findCurrent()->id)) {
+            $this->validValues = [];
+
+            $allowed = MassMailPermission::getForUser(User::findCurrent());
+
+            // Get all available institutes from database, grouped by faculty.
+            $faculties = DBManager::get()->fetchAll(
+                "SELECT `Institut_id`, `Name` FROM `Institute`
+                         WHERE `fakultaets_id` = `Institut_id` AND `Institut_id` IN (:allowed)
+                         ORDER BY `Name`",
+                ['allowed' => $allowed['allowed_institutes']]
+            );
+            foreach ($faculties as $f) {
+                $this->validValues[$f[$this->valuesDbIdField]] = $f[$this->valuesDbNameField];
+                $this->validValues[$f[$this->valuesDbIdField] . '_children'] =
+                    sprintf(_('%s und Untereinrichtungen'),
+                        $f[$this->valuesDbNameField]);
+                $institutes = DBManager::get()->fetchAll(
+                    "SELECT `Institut_id`, `Name` FROM `Institute`
+                             WHERE `fakultaets_id` = :fak AND `Institut_id` != :fak AND `Institut_id` IN (:allowed)
+                             ORDER BY `Name`",
+                    ['fak' => $f[$this->valuesDbIdField], 'allowed' => $allowed['allowed_institutes']]
+                );
+                foreach ($institutes as $i) {
+                    $this->validValues[$i[$this->valuesDbIdField]] = $i[$this->valuesDbNameField];
+                }
+            }
+
+            $institutes = DBManager::get()->fetchAll(
+                "SELECT `Institut_id`, `Name`
+                 FROM `Institute`
+                 WHERE `Institut_id` IN (:allowed)
+                   AND `Institut_id` NOT IN (:processed)
+                 ORDER BY `Name`",
+                [
+                    'allowed' => $allowed['allowed_institutes'],
+                    'processed' => count($this->validValues) > 0 ? array_keys($this->validValues) : ''
+                ]
+            );
+            foreach ($institutes as $i) {
+                $this->validValues[$i[$this->valuesDbIdField]] = $i[$this->valuesDbNameField];
+            }
+        }
+    }
+
+    public function getName()
+    {
+        return _('Einrichtung');
+    }
+
+    /**
+     * Gets all users belonging to a statusgroup with the given name. This is not done via statusgroup_id
+     * in ordner to enable several institutes as filter.
+     *
+     * @return array All users that are affected by the current condition
+     * field.
+     */
+    public function getUsers($restrictions = [])
+    {
+        $users = [];
+        if (MassMailPermission::has(User::findCurrent()->id, true)) {
+            $users = DBManager::get()->fetchFirst(
+                "SELECT DISTINCT `user_id` " .
+                "FROM `" . $this->userDataDbTable . "` " .
+                "WHERE `" . $this->userDataDbField . "`" . $this->compareOperator .
+                ":value AND `inst_perms` IN (:perms)", ['value' => $this->value,
+                'perms' => ['autor', 'tutor', 'dozent', 'admin']]
+            );
+        } else if (MassMailPermission::has(User::findCurrent()->id)) {
+
+            $allowed = MassMailPermission::getForUser(User::findCurrent());
+
+            $sql = "SELECT DISTINCT `" . $this->userDataDbTable . "`.`user_id` FROM `" . $this->userDataDbTable . "` ";
+            $where = "WHERE `" . $this->userDataDbTable . "`.`" . $this->userDataDbField . "`" . $this->compareOperator
+                . ":value AND `Institut_id` IN (:institutes) AND `inst_perms` IN (:perms)";
+            $parameters = [
+                'value' => $this->value,
+                'institutes' => $allowed['allowed_institutes'],
+                'perms' => ['autor', 'tutor', 'dozent', 'admin']
+            ];
+
+            $users = DBManager::get()->fetchFirst($sql.$where, $parameters);
+        }
+
+        return $users;
+    }
+}
diff --git a/lib/classes/UserFilterFields/MassMail/MassMailPermissionFilter.php b/lib/classes/UserFilterFields/MassMail/MassMailPermissionFilter.php
new file mode 100644
index 00000000000..eba33076beb
--- /dev/null
+++ b/lib/classes/UserFilterFields/MassMail/MassMailPermissionFilter.php
@@ -0,0 +1,111 @@
+<?php
+
+/**
+ * PermissionCondition.php
+ *
+ * All conditions concerning the semester of study in Stud.IP can be specified here.
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ *
+ * @author      Elmar Ludwig <elmar.ludwig@uos.de>
+ * @license     http://www.gnu.org/licenses/gpl-2.0.html GPL version 2
+ * @category    Stud.IP
+ */
+namespace UserFilterFields\MassMail;
+
+use UserFilterFields\PermissionCondition;
+use User;
+use DBManager;
+use MassMail\MassMailPermission;
+
+class MassMailPermissionFilter extends PermissionCondition
+{
+
+    public string $target = '';
+
+    public static $sortOrder = 10;
+
+    /**
+     * @see \UserFilterField::getTargets()
+     */
+    public static function getTargets()
+    {
+        return ['employees'];
+    }
+
+    /**
+     * @see UserFilterField::__construct
+     */
+    public function __construct($fieldId = '')
+    {
+        $this->userDataDbTable = 'auth_user_md5';
+        $this->userDataDbField = 'perms';
+
+        parent::__construct($fieldId);
+
+        $this->validValues = [
+            'autor' => _('Student/in'),
+            'tutor' => _('Tutor/in'),
+            'dozent' => _('Lehrende/r')
+        ];
+    }
+
+    /**
+     * Get this field's display name.
+     *
+     * @return String
+     */
+    public function getName()
+    {
+        return _('Globaler Status');
+    }
+
+    /**
+     * Gets all users with given gender.
+     *
+     * @return array All users that are affected by the current condition
+     * field.
+     */
+    public function getUsers($restrictions = array())
+    {
+        $users = [];
+        if (MassMailPermission::has(User::findCurrent()->id, true)) {
+            $users = DBManager::get()->fetchFirst("SELECT DISTINCT `user_id` " .
+                "FROM `" . $this->userDataDbTable . "` " .
+                "WHERE `" . $this->userDataDbField . "`" . $this->compareOperator .
+                "?", [$this->value]);
+        } else if (MassMailPermission::has(User::findCurrent()->id)) {
+
+            $allowed = MassMailPermission::getForUser(User::findCurrent());
+
+            $sql = "SELECT DISTINCT `" . $this->userDataDbTable . "`.`user_id` FROM `" . $this->userDataDbTable . "` ";
+            $where = "WHERE `" . $this->userDataDbTable . "`.`" . $this->userDataDbField . "`" . $this->compareOperator . ":value";
+            $parameters = ['value' => $this->value];
+
+            switch ($this->target) {
+                case 'employees':
+                    $sql .= "JOIN `user_inst` USING (`user_id`) ";
+                    $where .= " AND `user_inst`.`Institut_id` IN (:institutes) AND `user_inst`.`inst_perms` IN (:perms)";
+                    $parameters['institutes'] = $allowed['allowed_institutes'];
+                    $parameters['perms'] = ['autor', 'tutor', 'dozent', 'admin'];
+                    break;
+                case 'students':
+                default:
+                    $sql .= "JOIN `user_studiengang` USING (`user_id`) ";
+                    $where .= " AND (
+                            `user_studiengang`.`abschluss_id` IN (:degrees)
+                            OR `user_studiengang`.`fach_id` IN (:subjects)
+                        )";
+                    $parameters['degrees'] = $allowed['allowed_degrees'];
+                    $parameters['subjects'] = $allowed['allowed_subjects'];
+            }
+
+            $users = DBManager::get()->fetchFirst($sql.$where, $parameters);
+        }
+
+        return $users;
+    }
+}
diff --git a/lib/classes/UserFilterFields/MassMail/MassMailSelfAssignedInstituteFilter.php b/lib/classes/UserFilterFields/MassMail/MassMailSelfAssignedInstituteFilter.php
new file mode 100644
index 00000000000..8b732d20258
--- /dev/null
+++ b/lib/classes/UserFilterFields/MassMail/MassMailSelfAssignedInstituteFilter.php
@@ -0,0 +1,137 @@
+<?php
+
+namespace UserFilterFields\MassMail;
+
+use UserFilterField;
+use MassMail\MassMailPermission;
+use User;
+use DBManager;
+
+class MassMailSelfAssignedInstituteFilter extends UserFilterField
+{
+    public $valuesDbTable = 'Institute';
+    public $valuesDbIdField = 'Institut_id';
+    public $valuesDbNameField = 'Name';
+    public $userDataDbTable = 'user_inst';
+    public $userDataDbField = 'Institut_id';
+
+    public static $sortOrder = 9;
+
+    public static function getTargets()
+    {
+        return ['students'];
+    }
+
+    public function __construct($fieldId = '')
+    {
+        parent::__construct($fieldId);
+
+        $this->validCompareOperators = [
+            '='   => _('ist'),
+            '!=' => _('ist nicht'),
+        ];
+
+        if (MassMailPermission::has(User::findCurrent()->id, true)) {
+            // Get all available institutes from database, grouped by faculty.
+            $faculties = DBManager::get()->fetchAll(
+                "SELECT `Institut_id`, `Name` FROM `Institute`
+                         WHERE `fakultaets_id` = `Institut_id` ORDER BY `Name`"
+            );
+            foreach ($faculties as $f) {
+                $this->validValues[$f[$this->valuesDbIdField]] = $f[$this->valuesDbNameField];
+                $this->validValues[$f[$this->valuesDbIdField].'_children'] =
+                    sprintf(_('%s und Untereinrichtungen'),
+                        $f[$this->valuesDbNameField]);
+                $institutes = DBManager::get()->fetchAll(
+                    "SELECT `Institut_id`, `Name` FROM `Institute`
+                             WHERE `fakultaets_id` = :fak AND `Institut_id` != :fak ORDER BY `Name`",
+                    ['fak' => $f[$this->valuesDbIdField]]
+                );
+                foreach ($institutes as $i) {
+                    $this->validValues[$i[$this->valuesDbIdField]] = $i[$this->valuesDbNameField];
+                }
+            }
+        } else if (MassMailPermission::has(User::findCurrent()->id)) {
+            $this->validValues = [];
+
+            $allowed = MassMailPermission::getForUser(User::findCurrent());
+
+            // Get all available institutes from database, grouped by faculty.
+            $faculties = DBManager::get()->fetchAll(
+                "SELECT `Institut_id`, `Name` FROM `Institute`
+                         WHERE `fakultaets_id` = `Institut_id` AND `Institut_id` IN (:allowed)
+                         ORDER BY `Name`",
+                ['allowed' => $allowed['allowed_institutes']]
+            );
+            foreach ($faculties as $f) {
+                $this->validValues[$f[$this->valuesDbIdField]] = $f[$this->valuesDbNameField];
+                $this->validValues[$f[$this->valuesDbIdField] . '_children'] =
+                    sprintf(_('%s und Untereinrichtungen'),
+                        $f[$this->valuesDbNameField]);
+                $institutes = DBManager::get()->fetchAll(
+                    "SELECT `Institut_id`, `Name` FROM `Institute`
+                             WHERE `fakultaets_id` = :fak AND `Institut_id` != :fak AND `Institut_id` IN (:allowed)
+                             ORDER BY `Name`",
+                    ['fak' => $f[$this->valuesDbIdField], 'allowed' => $allowed['allowed_institutes']]
+                );
+                foreach ($institutes as $i) {
+                    $this->validValues[$i[$this->valuesDbIdField]] = $i[$this->valuesDbNameField];
+                }
+            }
+
+            $institutes = DBManager::get()->fetchAll(
+                "SELECT `Institut_id`, `Name`
+                 FROM `Institute`
+                 WHERE `Institut_id` IN (:allowed)
+                   AND `Institut_id` NOT IN (:processed)
+                 ORDER BY `Name`",
+                [
+                    'allowed' => $allowed['allowed_institutes'],
+                    'processed' => count($this->validValues) > 0 ? array_keys($this->validValues) : ''
+                ]
+            );
+            foreach ($institutes as $i) {
+                $this->validValues[$i[$this->valuesDbIdField]] = $i[$this->valuesDbNameField];
+            }
+        }
+    }
+
+    public function getName()
+    {
+        return _('Selbst zugeordnete Einrichtung');
+    }
+
+    /**
+     * Gets all users belonging to a statusgroup with the given name. This is not done via statusgroup_id
+     * in ordner to enable several institutes as filter.
+     *
+     * @return array All users that are affected by the current condition
+     * field.
+     */
+    public function getUsers($restrictions = [])
+    {
+        $users = [];
+        if (MassMailPermission::has(User::findCurrent()->id, true)) {
+            $users = DBManager::get()->fetchFirst("SELECT DISTINCT `user_id` " .
+                "FROM `" . $this->userDataDbTable . "` " .
+                "WHERE `" . $this->userDataDbField . "`" . $this->compareOperator .
+                "? AND `inst_perms` = 'user'", [$this->value]);
+        } else if (MassMailPermission::has(User::findCurrent()->id)) {
+
+            $allowed = MassMailPermission::getForUser(User::findCurrent());
+
+            $sql = "SELECT DISTINCT `" . $this->userDataDbTable . "`.`user_id` FROM `" . $this->userDataDbTable
+                . "`JOIN `user_inst` USING (`user_id`) ";
+            $where = "WHERE `" . $this->userDataDbTable . "`.`" . $this->userDataDbField . "`" . $this->compareOperator
+                . ":value AND `user_inst`.`Institut_id` IN (:institutes) AND `user_inst`.`inst_perms` = 'user'";
+            $parameters = [
+                'value' => $this->value,
+                'institutes' => $allowed['institutes']->pluck('id')
+            ];
+
+            $users = DBManager::get()->fetchFirst($sql.$where, $parameters);
+        }
+
+        return $users;
+    }
+}
diff --git a/lib/classes/UserFilterFields/MassMail/MassMailSemesterOfStudyFilter.php b/lib/classes/UserFilterFields/MassMail/MassMailSemesterOfStudyFilter.php
new file mode 100644
index 00000000000..f92c220079d
--- /dev/null
+++ b/lib/classes/UserFilterFields/MassMail/MassMailSemesterOfStudyFilter.php
@@ -0,0 +1,75 @@
+<?php
+
+namespace UserFilterFields\MassMail;
+
+use UserFilterFields\SemesterOfStudyCondition;
+
+class MassMailSemesterOfStudyFilter extends SemesterOfStudyCondition
+{
+    // --- ATTRIBUTES ---
+    public $valuesDbTable = 'user_studiengang';
+    public $valuesDbIdField = 'semester';
+    public $userDataDbTable = 'user_studiengang';
+    public $userDataDbField = 'semester';
+
+    /**
+     * @see \UserFilterField::getTargets()
+     */
+    public static function getTargets()
+    {
+        return ['students'];
+    }
+
+    /**
+     * @see UserFilterField::__construct
+     */
+    public function __construct($fieldId='')
+    {
+        parent::__construct($fieldId);
+        $this->relations = [
+            'MassMailDegreeFilter' => [
+                'local_field' => 'abschluss_id',
+                'foreign_field' => 'abschluss_id'
+            ],
+            'MassMailSubjectFilter' => [
+                'local_field' => 'fach_id',
+                'foreign_field' => 'fach_id'
+            ]
+        ];
+        $this->validCompareOperators = [
+            '>=' => _('mindestens'),
+            '<=' => _('höchstens'),
+            '=' => _('ist'),
+            '!=' => _('ist nicht')
+        ];
+        if (isset(self::$cached_valid_values[static::class])) {
+            $this->validValues = self::$cached_valid_values[static::class];
+        } else {
+            // Initialize to some value in case there are no semester numbers.
+            $maxsem = 15;
+            // Calculate the maximal available semester.
+            $stmt = \DBManager::get()->query("SELECT MAX(" . $this->valuesDbIdField . ") AS maxsem " .
+                "FROM `" . $this->valuesDbTable . "`");
+            if ($current = $stmt->fetch(\PDO::FETCH_ASSOC)) {
+                if ($current['maxsem']) {
+                    $maxsem = $current['maxsem'];
+                }
+            }
+            for ($i = 1; $i <= $maxsem; $i++) {
+                $this->validValues[$i] = $i;
+            }
+            self::$cached_valid_values[static::class] = $this->validValues;
+        }
+    }
+
+    /**
+     * Get this field's display name.
+     *
+     * @return String
+     */
+    public function getName()
+    {
+        return _('Fachsemester');
+    }
+
+}
diff --git a/lib/classes/UserFilterFields/MassMail/MassMailStatusgroupFilter.php b/lib/classes/UserFilterFields/MassMail/MassMailStatusgroupFilter.php
new file mode 100644
index 00000000000..61a04be882a
--- /dev/null
+++ b/lib/classes/UserFilterFields/MassMail/MassMailStatusgroupFilter.php
@@ -0,0 +1,88 @@
+<?php
+
+namespace UserFilterFields\MassMail;
+
+use UserFilterField;
+use MassMail\MassMailPermission;
+use User;
+use DBManager;
+
+class MassMailStatusgroupFilter extends UserFilterField
+{
+    public $valuesDbTable = 'statusgruppen';
+    public $valuesDbIdField = 'statusgruppe_id';
+    public $valuesDbNameField = 'name';
+    public $userDataDbTable = 'statusgruppe_user';
+    public $userDataDbField = 'statusgruppe_id';
+
+    public static $sortOrder = 10;
+
+    public static function getTargets()
+    {
+        return ['employees'];
+    }
+
+    public function __construct($fieldId = '')
+    {
+        parent::__construct($fieldId);
+
+        $this->validCompareOperators = [
+            '='   => _('ist'),
+            '!=' => _('ist nicht'),
+        ];
+
+        $this->validValues = [];
+        if (MassMailPermission::has(User::findCurrent()->id, true)) {
+            $this->validValues = DBManager::get()->fetchFirst(
+                "SELECT DISTINCT `name` FROM `statusgruppen` ORDER BY `name` ASC"
+            );
+        } else if (MassMailPermission::has(User::findCurrent()->id)) {
+            $allowed = MassMailPermission::getForUser(User::findCurrent());
+
+            $this->validValues = DBManager::get()->fetchFirst(
+                "SELECT DISTINCT `name` FROM `statusgruppen` WHERE `range_id` IN (:institutes) ORDER BY `name` ASC",
+                ['institutes' => $allowed['allowed_institutes']]
+            );
+        }
+    }
+
+    public function getName()
+    {
+        return _('Statusgruppe');
+    }
+
+    /**
+     * Gets all users belonging to a statusgroup with the given name. This is not done via statusgroup_id
+     * in ordner to enable several institutes as filter.
+     *
+     * @return array All users that are affected by the current condition
+     * field.
+     */
+    public function getUsers($restrictions = [])
+    {
+        $users = [];
+        if (MassMailPermission::has(User::findCurrent()->id, true)) {
+            $users = DBManager::get()->fetchFirst("SELECT DISTINCT `user_id` " .
+                "FROM `" . $this->userDataDbTable . "` " .
+                "WHERE `" . $this->userDataDbField . "`" . $this->compareOperator .
+                "?", [$this->value]);
+        } else if (MassMailPermission::has(User::findCurrent()->id)) {
+
+            $allowed = MassMailPermission::getForUser(User::findCurrent());
+
+            $sql = "SELECT DISTINCT `" . $this->userDataDbTable . "`.`user_id` FROM `" . $this->userDataDbTable
+                . "`JOIN `user_inst` USING (`user_id`) ";
+            $where = "WHERE `" . $this->userDataDbTable . "`.`" . $this->userDataDbField . "`" . $this->compareOperator
+                . ":value AND `user_inst`.`Institut_id` IN (:institutes) AND `user_inst`.`inst_perms` IN (:perms)";
+            $parameters = [
+                'value' => $this->value,
+                'institutes' => $allowed['allowed_institutes'],
+                'perms' => ['autor', 'tutor', 'dozent', 'admin']
+            ];
+
+            $users = DBManager::get()->fetchFirst($sql.$where, $parameters);
+        }
+
+        return $users;
+    }
+}
diff --git a/lib/classes/UserFilterFields/MassMail/MassMailSubjectFilter.php b/lib/classes/UserFilterFields/MassMail/MassMailSubjectFilter.php
new file mode 100644
index 00000000000..977b2775924
--- /dev/null
+++ b/lib/classes/UserFilterFields/MassMail/MassMailSubjectFilter.php
@@ -0,0 +1,125 @@
+<?php
+
+namespace UserFilterFields\MassMail;
+
+use UserFilterFields\SubjectCondition;
+
+class MassMailSubjectFilter extends SubjectCondition
+{
+    /**
+     * @see \UserFilterField::getTargets()
+     */
+    public static function getTargets()
+    {
+        return ['students'];
+    }
+
+    public function __construct($fieldId = '')
+    {
+        parent::__construct($fieldId);
+
+        if (!\MassMail\MassMailPermission::has(\User::findCurrent()->id, true)) {
+            $this->validValues = [];
+
+            $permission = \MassMail\MassMailPermission::getForUser(\User::findCurrent(), true);
+
+            foreach ($permission['allowed_subjects'] as [$id, $name]) {
+                $this->validValues[$id] = (string) $name;
+            }
+        }
+    }
+
+    public function getUsers($restrictions = [])
+    {
+        $users = [];
+
+        if (\MassMail\MassMailPermission::has(\User::findCurrent()->id, true)) {
+            $users = parent::getUsers($restrictions);
+        } else if (count($this->validValues) > 0) {
+            // Standard query getting the values without respecting other values.
+            $select = "SELECT DISTINCT `" . $this->userDataDbTable . "`.`user_id` ";
+            $from = "FROM `" . $this->userDataDbTable . "` ";
+            $where = "WHERE `" . $this->userDataDbTable . "`.`" . $this->userDataDbField .
+                "`" . $this->compareOperator . "?";
+            $parameters = [$this->value];
+            $joinedTables = [
+                $this->userDataDbTable => true
+            ];
+            // Check if there are restrictions given.
+            foreach ($restrictions as $otherField => $restriction) {
+                // We only take the value into consideration if it represents a valid restriction.
+                if ($this->relations[$otherField]) {
+                    // Do we need to join in another table?
+                    if (!$joinedTables[$restriction['table']]) {
+                        $joinedTables[$restriction['table']] = true;
+                        $from .= " INNER JOIN `" . $restriction['table'] . "` ON (`" .
+                            $this->userDataDbTable . "`.`" .
+                            $this->relations[$otherField]['local_field'] . "`=`" .
+                            $restriction['table'] . "`.`" .
+                            $this->relations[$otherField]['foreign_field'] . "`)";
+                    }
+                    // Expand WHERE statement with the value from restriction.
+                    $where .= " AND `" . $restriction['table'] . "`.`" .
+                        $restriction['field'] . "`" . $restriction['compare'] . "?";
+                    $parameters[] = $restriction['value'];
+                }
+            }
+
+            $where .= " AND `" . $this->userDataDbTable . "`.`" . $this->userDataDbField . "` IN (?)";
+            $parameters[] = array_keys($this->validValues);
+
+            // Get all the users that fulfill the condition.
+            $users = \DBManager::get()->fetchFirst($select . $from . $where, $parameters);
+        }
+
+        return $users;
+    }
+
+    /**
+     * Gets the value for the given user that is relevant for this
+     * condition field. Here, this method looks up the study degree(s)
+     * for the user. These can then be compared with the required degrees
+     * whether they fit.
+     *
+     * @param  String $userId User to check.
+     * @param  array $additional conditions that are required for check.
+     * @return array The value(s) for this user.
+     */
+    public function getUserValues($userId, $additional = null)
+    {
+        if (\MassMail\MassMailPermission::has(\User::findCurrent()->id, true)) {
+            $result = parent::getUserValues($userId, $additional);
+        } else {
+            $result = [];
+            $query = "SELECT DISTINCT `" . $this->userDataDbField . "` " .
+                "FROM `" . $this->userDataDbTable . "` " .
+                "WHERE `user_id`=?";
+            $parameters = [$userId];
+            // Additional requirements given...
+            if (is_array($additional)) {
+
+                // Don't use the same database field twice as this can only get ugly.
+                $usedFields = [$this->userDataDbField];
+
+                foreach ($additional as $a_condition) {
+                    if (
+                        $a_condition->id != $this->id
+                        && $this->userDataDbTable === $a_condition->userDataDbTable
+                        && !in_array($a_condition->userDataDbField, $usedFields)
+                    ) {
+                        $query .= " AND `" . $a_condition->userDataDbField . "` " . $a_condition->compareOperator . "?";
+                        $parameters[] = $a_condition->value;
+                    }
+                }
+            }
+            // Get semester of study for user.
+            $stmt = \DBManager::get()->prepare($query);
+            $stmt->execute($parameters);
+            while ($current = $stmt->fetch(\PDO::FETCH_ASSOC)) {
+                $result[] = $current[$this->userDataDbField];
+            }
+        }
+        return $result;
+    }
+
+}
diff --git a/lib/classes/admission/userfilter/PermissionCondition.php b/lib/classes/UserFilterFields/PermissionCondition.php
similarity index 90%
rename from lib/classes/admission/userfilter/PermissionCondition.php
rename to lib/classes/UserFilterFields/PermissionCondition.php
index fe9458c6f3a..10212c76128 100644
--- a/lib/classes/admission/userfilter/PermissionCondition.php
+++ b/lib/classes/UserFilterFields/PermissionCondition.php
@@ -1,4 +1,5 @@
 <?php
+
 /**
  * PermissionCondition.php
  *
@@ -13,9 +14,11 @@
  * @license     http://www.gnu.org/licenses/gpl-2.0.html GPL version 2
  * @category    Stud.IP
  */
-class PermissionCondition extends UserFilterField
+namespace UserFilterFields;
+
+class PermissionCondition extends \UserFilterField
 {
-    public $sortOrder = 7;
+    public static $sortOrder = 7;
 
     /**
      * @see UserFilterField::__construct
diff --git a/lib/classes/admission/userfilter/SemesterOfStudyCondition.php b/lib/classes/UserFilterFields/SemesterOfStudyCondition.php
similarity index 89%
rename from lib/classes/admission/userfilter/SemesterOfStudyCondition.php
rename to lib/classes/UserFilterFields/SemesterOfStudyCondition.php
index 5794f759e1e..f66789fc044 100644
--- a/lib/classes/admission/userfilter/SemesterOfStudyCondition.php
+++ b/lib/classes/UserFilterFields/SemesterOfStudyCondition.php
@@ -1,4 +1,5 @@
 <?php
+
 /**
  * SemesterOfStudyCondition.php
  *
@@ -13,7 +14,9 @@
  * @license     http://www.gnu.org/licenses/gpl-2.0.html GPL version 2
  * @category    Stud.IP
  */
-class SemesterOfStudyCondition extends UserFilterField
+namespace UserFilterFields;
+
+class SemesterOfStudyCondition extends \UserFilterField
 {
     // --- ATTRIBUTES ---
     public $valuesDbTable = 'user_studiengang';
@@ -21,7 +24,7 @@ class SemesterOfStudyCondition extends UserFilterField
     public $userDataDbTable = 'user_studiengang';
     public $userDataDbField = 'semester';
 
-    public $sortOrder = 4;
+    public static $sortOrder = 4;
 
     // --- OPERATIONS ---
 
@@ -54,9 +57,9 @@ class SemesterOfStudyCondition extends UserFilterField
             // Initialize to some value in case there are no semester numbers.
             $maxsem = 15;
             // Calculate the maximal available semester.
-                $stmt = DBManager::get()->query("SELECT MAX(" . $this->valuesDbIdField . ") AS maxsem " .
+                $stmt = \DBManager::get()->query("SELECT MAX(" . $this->valuesDbIdField . ") AS maxsem " .
                     "FROM `" . $this->valuesDbTable . "`");
-            if ($current = $stmt->fetch(PDO::FETCH_ASSOC)) {
+            if ($current = $stmt->fetch(\PDO::FETCH_ASSOC)) {
                 if ($current['maxsem']) {
                     $maxsem = $current['maxsem'];
                 }
diff --git a/lib/classes/admission/userfilter/StgteilVersionCondition.php b/lib/classes/UserFilterFields/StgteilVersionCondition.php
similarity index 84%
rename from lib/classes/admission/userfilter/StgteilVersionCondition.php
rename to lib/classes/UserFilterFields/StgteilVersionCondition.php
index ec5c1f38033..59bb035aa1f 100644
--- a/lib/classes/admission/userfilter/StgteilVersionCondition.php
+++ b/lib/classes/UserFilterFields/StgteilVersionCondition.php
@@ -1,4 +1,5 @@
 <?php
+
 /**
  * StgteilVersionCondition.php
  *
@@ -13,7 +14,9 @@
  * @license     http://www.gnu.org/licenses/gpl-2.0.html GPL version 2
  * @category    Stud.IP
  */
-class StgteilVersionCondition extends UserFilterField
+namespace UserFilterFields;
+
+class StgteilVersionCondition extends \UserFilterField
 {
     // --- ATTRIBUTES ---
     public $valuesDbTable = 'mvv_stgteilversion';
@@ -22,14 +25,14 @@ class StgteilVersionCondition extends UserFilterField
     public $userDataDbTable = 'user_studiengang';
     public $userDataDbField = 'version_id';
 
-    public $sortOrder = 5;
+    public static $sortOrder = 5;
 
     public static $isParameterized = true;
 
     public static function getParameterizedTypes()
     {
-        if (Config::get()->DISPLAY_STGTEILVERSION_USERFILTER) {
-            $filter = new StgteilVersionCondition;
+        if (\Config::get()->DISPLAY_STGTEILVERSION_USERFILTER) {
+            $filter = new StgteilVersionCondition();
             $fields['StgteilVersionCondition'] = $filter->getName();
             return $fields;
         } else {
@@ -48,13 +51,13 @@ class StgteilVersionCondition extends UserFilterField
         ];
         if ($this->valuesDbNameField) {
             // Get all available values from database.
-            $stmt = DBManager::get()->query(
+            $stmt = \DBManager::get()->query(
                 "SELECT DISTINCT `version_id`, `fach`.`name` ".
                  "FROM `mvv_stgteilversion` LEFT JOIN mvv_stgteil USING (stgteil_id)".
                  "LEFT JOIN fach USING (fach_id)".
                  "WHERE `mvv_stgteilversion`.`stat` = 'genehmigt' ORDER BY `fach`.`name` ASC");
 
-            while ($current = $stmt->fetch(PDO::FETCH_ASSOC)) {
+            while ($current = $stmt->fetch(\PDO::FETCH_ASSOC)) {
                 $this->validValues[$current[$this->valuesDbIdField]] = $current[$this->valuesDbNameField];
             }
         }
@@ -66,7 +69,7 @@ class StgteilVersionCondition extends UserFilterField
         }
 
         foreach ($this->validValues as $version_id => $name) {
-            $stgteilversion = StgteilVersion::find($version_id);
+            $stgteilversion = \StgteilVersion::find($version_id);
             $this->validValues[$version_id] = $stgteilversion->getDisplayName();
         }
     }
diff --git a/lib/classes/admission/userfilter/SubjectCondition.php b/lib/classes/UserFilterFields/SubjectCondition.php
similarity index 92%
rename from lib/classes/admission/userfilter/SubjectCondition.php
rename to lib/classes/UserFilterFields/SubjectCondition.php
index 7aa5f26cc36..e9ac1a07510 100644
--- a/lib/classes/admission/userfilter/SubjectCondition.php
+++ b/lib/classes/UserFilterFields/SubjectCondition.php
@@ -1,4 +1,5 @@
 <?php
+
 /**
  * SubjectCondition.php
  *
@@ -13,7 +14,9 @@
  * @license     http://www.gnu.org/licenses/gpl-2.0.html GPL version 2
  * @category    Stud.IP
  */
-class SubjectCondition extends UserFilterField
+namespace UserFilterFields;
+
+class SubjectCondition extends \UserFilterField
 {
     // --- ATTRIBUTES ---
     public $valuesDbTable = 'fach';
@@ -22,7 +25,7 @@ class SubjectCondition extends UserFilterField
     public $userDataDbTable = 'user_studiengang';
     public $userDataDbField = 'fach_id';
 
-    public $sortOrder = 2;
+    public static $sortOrder = 2;
 
     // --- OPERATIONS ---
 
diff --git a/lib/classes/admission/userfilter/SubjectConditionAny.php b/lib/classes/UserFilterFields/SubjectConditionAny.php
similarity index 88%
rename from lib/classes/admission/userfilter/SubjectConditionAny.php
rename to lib/classes/UserFilterFields/SubjectConditionAny.php
index 3a3712ba040..c99bcb8dd7b 100644
--- a/lib/classes/admission/userfilter/SubjectConditionAny.php
+++ b/lib/classes/UserFilterFields/SubjectConditionAny.php
@@ -14,15 +14,15 @@
  * @category    Stud.IP
  */
 
-require_once realpath(__DIR__ . '/..') . '/UserFilterField.php';
+namespace UserFilterFields;
 
-class SubjectConditionAny extends UserFilterField
+class SubjectConditionAny extends \UserFilterField
 {
     // --- ATTRIBUTES ---
     public $userDataDbTable = 'user_studiengang';
     public $userDataDbField = 'fach_id';
 
-    public $sortOrder = 3;
+    public static $sortOrder = 3;
 
     // --- OPERATIONS ---
 
diff --git a/lib/classes/UserFilterRange.php b/lib/classes/UserFilterRange.php
new file mode 100644
index 00000000000..a5a53e1810f
--- /dev/null
+++ b/lib/classes/UserFilterRange.php
@@ -0,0 +1,29 @@
+<?php
+
+/**
+ * UserFilterRange.php
+ *
+ * An interface that provides information about necessary permissions for editing a UserFilter object.
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License as
+ * published by the Free Software Foundation; either version 2 of
+ * the License, or (at your option) any later version.
+ *
+ * @author      Thomas Hackl <thomas.hackl@uni-passau.de>
+ * @since       6.0
+ * @license     http://www.gnu.org/licenses/gpl-2.0.html GPL version 2
+ * @category    Stud.IP
+ */
+
+interface UserFilterRange {
+
+    /**
+     * Check whether the given user can edit the given UserFilter object.
+     * @param User $user
+     * @param UserFilter $filter
+     * @return bool
+     */
+    public function canEditFilter(User $user, UserFilter $filter): bool;
+
+}
diff --git a/lib/classes/admission/CourseSet.php b/lib/classes/admission/CourseSet.php
index e7f3dee9d39..d93cfb0e2ac 100644
--- a/lib/classes/admission/CourseSet.php
+++ b/lib/classes/admission/CourseSet.php
@@ -15,7 +15,7 @@
  * @category    Stud.IP
  */
 
-class CourseSet
+class CourseSet implements UserFilterRange
 {
     // --- ATTRIBUTES ---
 
@@ -969,6 +969,7 @@ class CourseSet
         }
         // Store all rules.
         foreach ($this->admissionRules as $rule) {
+            $rule->courseSetId = $this->id;
             // Store each rule...
             $rule->store();
             // ... and its connection to the current course set.
@@ -1194,4 +1195,47 @@ class CourseSet
         $this->admissionRules = $cloned_rules;
     }
 
+    /**
+     * @see UserFilterRange::canEdit()
+     */
+    public function canEditFilter(User $user, UserFilter $filter): bool
+    {
+        if ($GLOBALS['perm']->have_perm('root', $user->id)) {
+            return true;
+        }
+
+        // Check general permissions on course set creation/editing.
+        $permission = $GLOBALS['perm']->have_perm('admin', $user->id)
+            || (
+                Config::get()->ALLOW_DOZENT_COURSESET_ADMIN
+                && $GLOBALS['perm']->have_perm('dozent', $user->id)
+            );
+
+        // Get all rules where filter can be present.
+        $ruleTypes = array_filter(
+            $this->getAdmissionRules(),
+            fn($rule) => in_array(get_class($rule), [ConditionalAdmission::class, PreferentialAdmission::class])
+        );
+
+        // Get my institute's IDs.
+        $institutes = array_map(
+            fn ($i) => $i['Institut_id'],
+            Institute::getMyInstitutes($user->id)
+        );
+        $matchingInstitutes = array_intersect(array_keys($this->institutes), $institutes);
+
+        /*
+         * Check whether:
+         * - this course set has rules than can have UserFilter objects
+         * - the given user is allowed to create/edit course sets at all
+         * - this course set belongs to the given user or is not private and belongs to one of this user's institutes
+         */
+        return $permission
+            && count($ruleTypes) > 0
+            && (
+                $this->user_id === $user->id
+                || !$this->private && count($matchingInstitutes) > 0
+            );
+    }
+
 } /* end of class CourseSet */
diff --git a/lib/classes/forms/CheckboxCollectionInput.php b/lib/classes/forms/CheckboxCollectionInput.php
new file mode 100644
index 00000000000..7859b9ac6b5
--- /dev/null
+++ b/lib/classes/forms/CheckboxCollectionInput.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace Studip\Forms;
+
+class CheckboxCollectionInput extends Input
+{
+    public function render()
+    {
+        $template = $GLOBALS['template_factory']->open('forms/checkbox_collection_input');
+        $template->title = $this->title;
+        $template->name = $this->name;
+        $template->selected = $this->value;
+        $template->required = $this->required;
+
+        $template->collapsable = $this->attributes['collapsable'] ?? false;
+        if (isset($this->attributes['collapsable'])) {
+            unset($this->attributes['collapsable']);
+        }
+        $options = $this->extractOptionsFromAttributes($this->attributes);
+
+        $template->attributes = arrayToHtmlAttributes($this->attributes);
+        $template->options = $options;
+        return $template->render();
+    }
+}
diff --git a/lib/classes/forms/Fieldset.php b/lib/classes/forms/Fieldset.php
index e7bced0a1b8..d1915bd3d52 100644
--- a/lib/classes/forms/Fieldset.php
+++ b/lib/classes/forms/Fieldset.php
@@ -5,6 +5,8 @@ namespace Studip\Forms;
 class Fieldset extends Part
 {
     protected $legend = null;
+    protected bool $collapsable = false;
+    protected bool $collapsed = false;
 
     public function __construct($legend = null)
     {
@@ -16,10 +18,25 @@ class Fieldset extends Part
         $this->legend = $legend;
     }
 
+
+    public function setCollapsable(bool $state = true): Fieldset
+    {
+        $this->collapsable = $state;
+        return $this;
+    }
+
+    public function setCollapsed(bool $state = true): Fieldset
+    {
+        $this->collapsed = $state;
+        return $this;
+    }
+
     public function render()
     {
         $template = $GLOBALS['template_factory']->open('forms/fieldset');
         $template->legend = $this->legend;
+        $template->collapsable = $this->collapsable;
+        $template->collapsed = $this->collapsable && $this->collapsed;
         $template->parts = $this->parts;
         return $template->render();
     }
diff --git a/lib/classes/forms/FileInput.php b/lib/classes/forms/FileInput.php
new file mode 100644
index 00000000000..5862b3c8788
--- /dev/null
+++ b/lib/classes/forms/FileInput.php
@@ -0,0 +1,23 @@
+<?php
+
+namespace Studip\Forms;
+
+class FileInput extends Input
+{
+
+    public function render()
+    {
+        $template = $GLOBALS['template_factory']->open('forms/file_input');
+        $template->title = $this->title;
+        $template->name = $this->name;
+        $template->folder = $this->value;
+        $template->id = md5(uniqid());
+        $template->uploadUrl = $this->attributes['upload_url'];
+        $template->multiple = $this->attributes['multiple'] ?? '';
+        $template->accept = $this->attributes['accept'] ?? '*/*';
+        $template->required = $this->attributes['required'] ?? '';
+
+        return $template->render();
+    }
+
+}
diff --git a/lib/classes/forms/Form.php b/lib/classes/forms/Form.php
index 0148c9ccb81..f9b27cd3cee 100644
--- a/lib/classes/forms/Form.php
+++ b/lib/classes/forms/Form.php
@@ -151,7 +151,9 @@ class Form extends Part
 
         //Now initializing the fieldset:
         $fieldset = new Fieldset($params['legend'] ?: _("Daten"));
-        $fieldset->setContextObject($object);
+        $fieldset->setContextObject($object)
+            ->setCollapsable($params['collapsable'] ?? false)
+            ->setCollapsed($params['collapsed'] ?? false);
         $this->addPart($fieldset);
 
         foreach ($fields as $fieldname => $fielddata) {
@@ -578,4 +580,20 @@ class Form extends Part
         }
         return $value;
     }
+
+    /**
+     * Checks whether this form has a file input and thus needs its enctype set.
+     * @return bool
+     */
+    public function hasFileInput()
+    {
+        foreach ($this->getAllInputs() as $input) {
+            if (get_class($input) === FileInput::class) {
+                return true;
+            }
+        }
+
+        return false;
+    }
+
 }
diff --git a/lib/classes/forms/QuicksearchListInput.php b/lib/classes/forms/QuicksearchListInput.php
new file mode 100644
index 00000000000..3cbc29a35d0
--- /dev/null
+++ b/lib/classes/forms/QuicksearchListInput.php
@@ -0,0 +1,19 @@
+<?php
+
+namespace Studip\Forms;
+
+class QuicksearchListInput extends Input
+{
+    public function render()
+    {
+        $template = $GLOBALS['template_factory']->open('forms/quicksearchlist_input');
+        $template->title = $this->title;
+        $template->name = $this->name;
+        $template->value = $this->value;
+        $template->id = md5(uniqid());
+        $template->required = $this->required;
+        $template->attributes = arrayToHtmlAttributes($this->attributes);
+
+        return $template->render();
+    }
+}
diff --git a/lib/classes/forms/SerialWysiwygInput.php b/lib/classes/forms/SerialWysiwygInput.php
new file mode 100644
index 00000000000..3d4551f248c
--- /dev/null
+++ b/lib/classes/forms/SerialWysiwygInput.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace Studip\Forms;
+
+use MassMail\MassMailMarker;
+
+class SerialWysiwygInput extends WysiwygInput
+{
+
+    public function render()
+    {
+        if (!isset($this->attributes['id'])) {
+            $id = md5(uniqid());
+            $this->attributes['id'] = $id;
+        } else {
+            $id = $this->attributes['id'];
+        }
+
+        $template = $GLOBALS['template_factory']->open('forms/serial_wysiwyg_input');
+        $template->title = $this->title;
+        $template->name = $this->name;
+        $template->value = $this->value;
+        $template->id = $id;
+        $template->required = $this->required;
+        $template->markers = $this->attributes['markers'];
+        $template->attributes = $this->attributes;
+        return $template->render();
+    }
+
+    public function getRequestValue()
+    {
+        return \Studip\Markup::purifyHtml(\Request::get($this->name));
+    }
+}
diff --git a/lib/classes/forms/UserFilterInput.php b/lib/classes/forms/UserFilterInput.php
new file mode 100644
index 00000000000..0b415b72689
--- /dev/null
+++ b/lib/classes/forms/UserFilterInput.php
@@ -0,0 +1,60 @@
+<?php
+
+namespace Studip\Forms;
+
+/**
+ * The Text class represents a part of a form that displays a user filter selection.
+ */
+class UserFilterInput extends Input
+{
+
+    public function getValue()
+    {
+        $value = [];
+        foreach ($this->getContextObject()->filters as $connection) {
+            $filter = $connection->userfilter;
+            $one = [
+                'id' => $filter->getId(),
+                'attributes' => [
+                    'text' => $filter->toString(),
+                    'fields' => []
+                ]
+            ];
+            foreach ($filter->getFields() as $field) {
+                $one['attributes']['fields'][] = [
+                    'id' => $field->getId(),
+                    'attributes' => [
+                        'type' => get_class($field),
+                        'compare-operator' => $field->getCompareOperator(),
+                        'value' => $field->getValue()
+                    ]
+                ];
+            }
+            $value[] = $one;
+        }
+        return json_encode($value);
+    }
+
+    public function getRequestValue()
+    {
+        return json_decode(\Request::get($this->name), true);
+    }
+
+    public function hasValidation(): bool
+    {
+        return false;
+    }
+
+    public function render(): string
+    {
+        $template = $GLOBALS['template_factory']->open('forms/user_filter_input');
+        $template->title = $this->title;
+        $template->name = $this->name;
+        $template->value = $this->value;
+        $template->id = md5(uniqid());
+        $template->required = $this->required;
+        $template->attributes = arrayToHtmlAttributes($this->attributes);
+        return $template->render();
+    }
+
+}
diff --git a/lib/cronjobs/send_massmails.php b/lib/cronjobs/send_massmails.php
new file mode 100644
index 00000000000..f712f603769
--- /dev/null
+++ b/lib/cronjobs/send_massmails.php
@@ -0,0 +1,107 @@
+<?php
+/**
+ * send_massmails.php
+ *
+ * @author Thomas Hackl <hackl@data-quest.de>
+ * @access public
+ * @since  6.0
+ */
+
+/**
+ * Cronjob class to send massmails.
+ */
+class SendMassmailsJob extends CronJob
+{
+
+    /**
+     * Returns the name of the cronjob.
+     * @return string : name of the cronjob
+     */
+    public static function getName()
+    {
+        return _('Nachrichten an Zielgruppen senden');
+    }
+
+    /**
+     * Returns the description of the cronjob.
+     * @return string : description of the cronjob.
+     */
+    public static function getDescription()
+    {
+        return _('Sendet alle anstehenden Nachrichten an Zielgruppen und räumt bereits gesendete auf.');
+    }
+
+    /**
+     * Sends all mass mails.
+     * @param integer $last_result : not evaluated for execution, so any integer
+     * will do. Usually it would be a unix-timestamp of last execution. But in
+     * this case we don't care at all.
+     * @param array $parameters : not needed here
+     */
+    public function execute($last_result, $parameters = [])
+    {
+        // Find all messages that need to be sent:
+        foreach (\MassMail\MassMailMessage::findUnsent() as $message) {
+            // Mark message as "currently working on".
+            $message->locked = 1;
+            $message->store();
+
+            $messaging = new messaging();
+
+            // Markers present: this must be a personalized message to every recipient.
+            if ($message->hasMarkers()) {
+
+                foreach ($message->getRecipients() as $recipient) {
+
+                    $mail = new Message();
+                    $mail->setId($mail->getNewId());
+
+                    $result = $messaging->insert_message(
+                        $message->replaceMarkers(User::findOneByUsername($recipient)),
+                        $recipient,
+                        $message->sender_id,
+                        time(),
+                        $mail->id,
+                        '',
+                        '',
+                        $message->subject
+                    );
+
+                    echo sprintf("Sending message %s to %s\n", $message->subject, $recipient);
+                }
+
+            // No markers -> we can send this as one single message to everyone at once.
+            } else {
+
+                $mail = new Message();
+                $mail->setId($mail->getNewId());
+
+                $result = $messaging->insert_message(
+                    $message->message,
+                    $message->getRecipients(),
+                    $message->sender_id,
+                    time(),
+                    $mail->id,
+                    '',
+                    '',
+                    $message->subject
+                );
+
+                echo sprintf("Sending message %s to %u recipients\n", $message->subject, count($message->getRecipients()));
+            }
+
+            if ($result) {
+                echo "Success!\n";
+                $message->locked = 0;
+                $message->sent = 1;
+                $message->store();
+            }
+
+        }
+
+        // Now cleanup all messages that have been sent and are older than the configured number of days.
+        foreach (\MassMail\MassMailMessage::findObsolete() as $message) {
+            $message->delete();
+        }
+    }
+}
diff --git a/lib/models/MassMail/MassMailFilter.php b/lib/models/MassMail/MassMailFilter.php
new file mode 100644
index 00000000000..8d88c65a886
--- /dev/null
+++ b/lib/models/MassMail/MassMailFilter.php
@@ -0,0 +1,34 @@
+<?php
+
+namespace MassMail;
+
+class MassMailFilter extends \SimpleORMap
+{
+
+    protected static function configure($config = [])
+    {
+        $config['db_table'] = 'massmail_filter';
+
+        $config['additional_fields']['userfilter']['get'] = function ($entry) {
+            return new \UserFilter($entry->filter_id);
+        };
+        $config['registered_callbacks']['before_delete'][] = 'cbDeleteUserFilter';
+        $config['registered_callbacks']['after_store'][] = 'cbUpdateUserFilterRange';
+
+        parent::configure($config);
+    }
+
+    public function cbDeleteUserFilter()
+    {
+        $filter = new \UserFilter($this->filter_id);
+        $filter->delete();
+    }
+
+    public function cbUpdateUserFilterRange()
+    {
+        $filter = new \UserFilter($this->filter_id);
+        $filter->setRange(MassMailMessage::class, $this->message_id);
+        $filter->store();
+    }
+
+}
diff --git a/lib/models/MassMail/MassMailMarker.php b/lib/models/MassMail/MassMailMarker.php
new file mode 100644
index 00000000000..d3dca6e4f9d
--- /dev/null
+++ b/lib/models/MassMail/MassMailMarker.php
@@ -0,0 +1,181 @@
+<?php
+
+namespace MassMail;
+
+use \User, \DBManager, \StudipPDO, \PDO;
+
+class MassMailMarker extends \SimpleORMap
+{
+
+    /*
+     * This seems to be necessary because of the direct reference in getDescription.
+     * Otherwise a PHP warning is thrown.
+     */
+    public string $description;
+
+    protected static function configure($config = [])
+    {
+        $config['db_table'] = 'massmail_markers';
+
+        parent::configure($config);
+    }
+
+    public static function findAll($root = false) {
+        return $root
+            ? static::findBySQL("1 ORDER BY `position`")
+            : static::findbyRoot_only("0 ORDER BY `position`");
+    }
+
+    /**
+     * Replaces markers contained in the given text with their replacement value for the given user.
+     *
+     * @param string $text
+     * @param User $user
+     * @param MassMailMarker[] $markers
+     * @return string|string[]
+     */
+    public static function processText(string $text, User $user, array $markers) {
+        $find = [];
+        $replace = [];
+        foreach ($markers as $marker) {
+            if ((!$marker->root_only || MassMailPermission::has(User::findCurrent()->id, true))
+                && strpos($text, '{{' . $marker->marker . '}}') !== false
+                && $marker->type != 'token') {
+                $find[] = '{{' . $marker->marker . '}}';
+                $replace[] = $marker->replaceMarker($user);
+            }
+        }
+        $text = str_replace($find, $replace, $text);
+        return $text;
+    }
+
+    /**
+     * Replaces tokens in the given text with a token for the given user.
+     * @param int $message_id
+     * @param string $text
+     * @param User $user
+     * @return string|string[]
+     */
+    public static function processToken(int $message_id, string $text, User $user)
+    {
+        foreach (self::findByType('token') as $marker) {
+            if ((!$marker->root_only || MassMailPermission::has(User::findCurrent()->id, true)) &&
+                strpos($text, '{{' . $marker->marker . '}}') !== false) {
+                $text = str_replace('{{' . $marker->marker . '}}',
+                    $marker->getReplacementToken($message_id, $user),
+                    $text
+                );
+            }
+        }
+        return $text;
+    }
+
+    /**
+     * This is a helper get function which gets the translated marker description. As the regular i18 mechanism for
+     * translateable content is not working here (thie is just shown in the GUI but stored dynamically in the database)
+     * I really do not know how to do that otherwise.
+     *
+     * @return string
+     */
+    public function getDescription(): string
+    {
+        return _($this->description);
+    }
+
+    /**
+     * Replaces the current marker text according to the given user.
+     * @param User $user
+     * @return array|mixed|\SimpleORMapCollection|string|string[]|void|null
+     */
+    public function replaceMarker(User $user)
+    {
+        $replacement = $this->replacement;
+
+        switch ($user->geschlecht) {
+            case 2:
+                if ($this->replacement_female) {
+                    $replacement = $this->replacement_female;
+                }
+                break;
+            case 0:
+            case 3:
+                if ($this->replacement_unknown) {
+                    $replacement = $this->replacement_unknown;
+                }
+                break;
+        }
+
+        switch ($this->type) {
+            // Just plain text replacing the marker, just check if other markers are included here.
+            case 'text':
+                if (strpos($replacement, '{{') !== false) {
+                    $matches = [];
+                    preg_match_all('/{{([a-zA-Z0-9\-_]+)}}/m', $replacement, $matches);
+                    foreach ($matches[1] as $match) {
+                        $replacement = str_replace('{' . $match . '}',
+                            MassMailMarker::findOneByMarker(trim($match))->replaceMarker($user),
+                            $replacement
+                        );
+                    }
+                }
+                return $replacement;
+
+            // Content from one or more database columns replaces the marker.
+            case 'database':
+                $data = words($replacement);
+                $find = [];
+                $replace = [];
+                foreach ($data as $entry) {
+                    if (strpos($entry, '{') !== false) {
+                        $matches = [];
+                        preg_match_all('/{{([a-zA-Z0-9\-_]+)}}/m', $entry, $matches);
+                        foreach ($matches[1] as $match) {
+                            $replacement = str_replace($entry,
+                                MassMailMarker::findOneByMarker(trim($match))->replaceMarker($user),
+                                $replacement
+                            );
+                        }
+                    } else {
+                        // Extract the database fields...
+                        [$table, $column] = explode('.', $entry);
+                        // ... and query database for values to insert.
+                        $stmt = DBManager::get()->prepare("SELECT `:column`
+                                FROM `:table` WHERE `user_id` = :userid LIMIT 1");
+                        $stmt->bindParam('column', $column, StudipPDO::PARAM_COLUMN);
+                        $stmt->bindParam('table', $table, StudipPDO::PARAM_COLUMN);
+                        $stmt->bindParam('userid', $user->id);
+                        $stmt->execute();
+                        $dbdata = $stmt->fetch(PDO::FETCH_ASSOC);
+                        $replacement = str_replace($entry, $dbdata[$column], $replacement);
+                    }
+                }
+                // If we have empty values from database, there could be excess whitespace -> remove.
+                return trim(preg_replace('/(\s)+/', ' ', $replacement));
+
+            // The marker is replaced by the result of a function call.
+            case 'function':
+                $data = words($replacement);
+                $function = array_shift($data);
+                return call_user_func_array($function, $data);
+        }
+    }
+
+    /**
+     * Gets a token and assigns it to the given user.
+     */
+    public function getReplacementToken($message_id, $user): string
+    {
+        $token = MassMailToken::findOneBySQL(
+            "`message_id` = :id AND `user_id`IS NULL"
+        );
+
+        if ($token) {
+            $token->user_id = $user->id;
+            $token->store();
+            return $token->token;
+        } else {
+            throw new \Exception('No free token available.');
+        }
+    }
+
+}
diff --git a/lib/models/MassMail/MassMailMessage.php b/lib/models/MassMail/MassMailMessage.php
new file mode 100644
index 00000000000..d2cd8441c65
--- /dev/null
+++ b/lib/models/MassMail/MassMailMessage.php
@@ -0,0 +1,373 @@
+<?php
+
+namespace MassMail;
+
+use \Semester, \DBManager, \UserFilter, \Folder, \User, \Config;
+
+class MassMailMessage extends \SimpleORMap implements \UserFilterRange
+{
+
+    protected static function configure($config = [])
+    {
+        $config['db_table'] = 'massmail_messages';
+
+        $config['serialized_fields']['config'] = \JSONArrayObject::class;
+
+        $config['has_one']['author'] = [
+            'class_name' => User::class,
+            'foreign_key' => 'author_id',
+            'assoc_foreign_key' => 'user_id'
+        ];
+        $config['has_one']['sender'] = [
+            'class_name' => User::class,
+            'foreign_key' => 'sender_id',
+            'assoc_foreign_key' => 'user_id'
+        ];
+        $config['has_many']['filters'] = [
+            'class_name' => MassMailFilter::class,
+            'assoc_foreign_key' => 'message_id',
+            'on_store' => 'store',
+            'on_delete' => 'delete'
+        ];
+        $config['has_one']['folder'] = [
+            'class_name' => Folder::class,
+            'foreign_key' => 'folder_id',
+            'assoc_foreign_key' => 'id',
+            'on_store' => 'store',
+            'on_delete' => 'delete'
+        ];
+        $config['has_many']['tokens'] = [
+            'class_name' => MassMailToken::class,
+            'assoc_foreign_key' => 'message_id',
+            'on_store' => 'store',
+            'on_delete' => 'delete'
+        ];
+
+        parent::configure($config);
+    }
+
+    /**
+     * Finds all messages that are currently due to be sent.
+     * @return MassMailMessage[]
+     */
+    public static function findUnsent(): array
+    {
+        return static::findBySQL(
+            "`is_template` = 0
+                AND `sent` = 0
+                AND `locked` = 0
+                AND (`send_at_date` IS NULL OR `send_at_date` <= UNIX_TIMESTAMP())
+            ORDER BY `mkdate`"
+        );
+    }
+
+    /**
+     * Finds all messages that have been successfully sent and can be deleted now according to their age.
+     * @return MassMailMessage[]
+     */
+    public static function findObsolete(): array
+    {
+        return static::findBySQL(
+            "`sent` = 1 AND `is_template` = 0 AND `protected` = 0 AND `chdate` <= :threshold",
+            ['threshold' => time() - (Config::get()->MASSMAIL_GC_DAYS * 24 * 60 * 60)]
+        );
+    }
+
+    /**
+     * Possible targets for mass mails.
+     * @return array
+     */
+    public static function getTargets(): array
+    {
+        return [
+            'all' => _('alle'),
+            'students' => _('Studierende'),
+            'employees' => _('Beschäftigte'),
+            'lecturers' => _('Aktive Lehrende'),
+            'courses' => _('Veranstaltungen'),
+            'usernames' => _('Liste von Benutzernamen'),
+        ];
+    }
+
+    /**
+     * Fetches all semesters.
+     * @return array
+     */
+    public static function getSemesters(): array
+    {
+        $semesters = [];
+
+        foreach (array_reverse(Semester::getAll()) as $one) {
+            $semesters[$one->id] = $one->name;
+        }
+
+        return $semesters;
+    }
+
+    /**
+     * Get the folder belonging to this message. If none is found, it will be auto-created as a
+     * personal folder of the current user..
+     * @param string $id
+     * @return \FolderType
+     */
+    public function findFolder(string $id): \FolderType
+    {
+        $messageFolder = Folder::findOneBySQL(
+            "`range_id` = :id AND `range_type` = 'massmail'",
+            ['id' => $id]
+        );
+        if (!$messageFolder) {
+            $messageFolder = new \StandardFolder([
+                'user_id' => User::findCurrent()->id,
+                'range_id' => $id,
+                'range_type' => 'massmail',
+                'parent_id' => 'root',
+                'name' => _('Nachricht an Zielgruppen')
+            ]);
+            $messageFolder->store();
+        } else {
+            $messageFolder = $messageFolder->getTypedFolder();
+        }
+
+        return $messageFolder;
+    }
+
+    /**
+     * Gets the real recipient list for this message.
+     * @return string[] the usernames that will get this message.
+     */
+    public function getRecipients(): array
+    {
+        $ids = [];
+
+        switch ($this->target) {
+            // Everyone studying something or working at an institute.
+            case 'all':
+
+                $sql = "SELECT DISTINCT `user_id` FROM `user_studiengang`";
+                $parameters = [];
+                if (!MassMailPermission::has($this->author_id, true)) {
+
+                    $permission = MassMailPermission::getForUser($this->author);
+
+                    $sql .= " WHERE `abschluss_id` IN (:degrees) OR `fach_id` IN (:subjects)";
+                    $parameters = [
+                        'degrees' => $permission['allowed_degrees'],
+                        'subjects' => $permission['allowed_subjects']
+                    ];
+                }
+                $students = DBManager::get()->fetchFirst($sql, $parameters);
+
+                $sql = "SELECT DISTINCT `user_id` FROM `user_inst` WHERE `inst_perms` IN (:perms)";
+                $parameters = ['perms' => ['autor', 'tutor', 'dozent']];
+                if (!MassMailPermission::has($this->author_id, true)) {
+                    $sql .= " AND `Institut_id` IN (:institutes)";
+                    $parameters = [
+                        'institutes' => $permission['allowed_institutes']
+                    ];
+                }
+                $employees = DBManager::get()->fetchFirst($sql, $parameters);
+
+                $ids = array_unique(array_merge($students, $employees));
+
+                break;
+
+            // Students are users with at least one studycourse assignment in user_studiengang.
+            case 'students':
+
+                $sql = "SELECT DISTINCT `user_id` FROM `user_studiengang`";
+                $parameters = [];
+
+                if (!MassMailPermission::has($this->author_id, true)) {
+                    $permission = MassMailPermission::getForUser($this->author);
+
+                    $sql .= " WHERE `abschluss_id` IN (:degrees) OR `fach_id` IN (:subjects)";
+                    $parameters = [
+                        'degrees' =>  $permission['allowed_degrees'],
+                        'subjects' => $permission['allowed_subjects']
+                    ];
+                }
+                $ids = DBManager::get()->fetchFirst($sql, $parameters);
+
+                if (count($this->filters) > 0) {
+
+                    $filtered = [];
+                    foreach ($this->filters as $filter) {
+                        $f = new UserFilter($filter->filter_id);
+                        $filtered = array_merge($filtered, $f->getUsers());
+                    }
+
+                    $ids = array_unique(array_intersect($ids, $filtered));
+
+                }
+
+                break;
+
+            // Employees are users with at least one institute assignment at 'autor" level or more.
+            case 'employees':
+
+                $sql = "SELECT DISTINCT `user_id` FROM `user_inst` WHERE `inst_perms` IN (:perms)";
+                $parameters = ['perms' => ['autor', 'tutor', 'dozent']];
+                if (!MassMailPermission::has($this->author_id, true)) {
+                    $permission = MassMailPermission::getForUser($this->author);
+
+                    $sql .= " AND `Institut_id` IN (:institutes)";
+                    $parameters = [
+                        'institutes' => $permission->allowed_institutes ? $permission->allowed_institutes->pluck('id') : []
+                    ];
+                }
+                $ids = DBManager::get()->fetchFirst($sql, $parameters);
+
+                if (count($this->filters) > 0) {
+
+                    $filtered = [];
+                    foreach ($this->filters as $filter) {
+                        $f = new UserFilter($filter->filter_id);
+                        $filtered = array_merge($filtered, $f->getUsers());
+                    }
+
+                    $ids = array_unique(array_intersect($ids, $filtered));
+
+                }
+
+                break;
+
+            // Course members having the specified permission level.
+            case 'courses':
+
+                $courses = array_map(
+                    fn ($course) => $course['id'],
+                    $this->config['courses']->getArrayCopy()
+                );
+                $permission = $this->config['perm'];
+
+                $ids = DBManager::get()->fetchFirst(
+                    "SELECT DISTINCT `user_id` FROM `seminar_user` WHERE `Seminar_id` IN (:courses) AND `status` = :perm",
+                    ['courses' => $courses, 'perm' => $permission]
+                );
+
+                break;
+
+            // Lecturers of at least one course in the given semester
+            case 'lecturers':
+
+                $ids = DBManager::get()->fetchFirst(
+                    "SELECT DISTINCT u.`user_id` FROM `seminar_user` u
+                        LEFT JOIN `semester_courses` sc ON (sc.`course_id` = u.`Seminar_id`)
+                        JOIN `seminare` s ON (s.`Seminar_id` = u.`Seminar_id`)
+                        JOIN `sem_types` t ON (t.`id` = s.`status`)
+                    WHERE (sc.`semester_id` = :semester OR sc.`semester_id` IS NULL)
+                        AND t.`class` IN (:categories)
+                        AND u.`status` = 'dozent'",
+                    [
+                        'semester' => $this->config['semester'],
+                        'categories' => Config::get()->MASSMAIL_LECTURER_SEM_CATEGORIES
+                    ]
+                );
+
+                break;
+
+            case 'usernames':
+
+                $ids = DBManager::get()->fetchFirst(
+                    "SELECT DISTINCT `user_id` FROM `auth_user_md5` WHERE `Username` IN (:usernames)",
+                    ['usernames' => explode("\n", $this->config['usernames'])]
+                );
+        }
+
+        return DBManager::get()->fetchFirst(
+            "SELECT DISTINCT `username`
+            FROM `auth_user_md5`
+            WHERE `visible` != :visible
+                AND `locked` = :locked
+                AND `user_id` IN (:ids)
+                AND `username` NOT IN (:exclude)
+            ORDER BY `username`",
+            [
+                'visible' => 'never',
+                'locked' => 0,
+                'ids' => $ids,
+                'exclude' => $this->exclude_users ? explode("\n", $this->exclude_users) : ['']
+            ]
+        );
+    }
+
+    /**
+     * Checks whether this message has replacement markers in its message text.
+     * @param $with_tokens Check for tokens or just for "normal" markers?
+     * @return bool
+     */
+    public function hasMarkers($type = 'all'): bool
+    {
+        $markers = MassMailMarker::findAndMapBySQL(
+            fn($m) => '{{' . $m->marker . '}}',
+            $type === 'all' ? "1" : "`type` = :type",
+            $type === 'all' ? [] : ['type' => $type]
+        );
+        foreach ($markers as $marker) {
+            if (str_contains($this->message, $marker)) {
+                return true;
+            }
+        }
+        return false;
+    }
+
+    /**
+     * Replaces serial message markers with the data of the given user.
+     * @param User $user
+     * @return string
+     */
+    public function replaceMarkers(User $user): string
+    {
+        $text = MassMailMarker::processText($this->message, $user, $this->getMarkers());
+
+        if (count($this->tokens) > 0) {
+            $text = MassMailMarker::processToken($this->message, $text, $user);
+        }
+
+        return $text;
+    }
+
+    /**
+     * Get available serial message markers, optionally including person token markers
+     * @param bool $with_tokens
+     * @return array
+     */
+    private function getMarkers($with_tokens = true): array
+    {
+        $found = [];
+        $markers = MassMailMarker::findBySQL($with_tokens ? "1" : "`type` != 'token'");
+        foreach ($markers as $marker) {
+            if (str_contains($this->message, $marker->marker)) {
+                $found[] = $marker;
+            }
+        }
+        return $found;
+    }
+
+    /**
+     * Get message attachments (excluding files used fot token generation)
+     * @return array|\FileRef[]
+     */
+    public function getAttachments()
+    {
+        $files = [];
+        $folder = Folder::find($this->folder_id);
+
+        return array_filter(
+            $folder->getTypedFolder()->getFiles(),
+            fn ($ref) => !isset($ref->file->metadata['is_token_file'])
+        );
+    }
+
+    /**
+     * @see UserFilterRange::canEdit()
+     */
+    public function canEditFilter(User $user, UserFilter $filter): bool
+    {
+        return MassMailPermission::has($user->id, true)
+            || MassMailPermission::has($user->id, false) && $this->creator_id === $user->id;
+
+    }
+
+}
diff --git a/lib/models/MassMail/MassMailPermission.php b/lib/models/MassMail/MassMailPermission.php
new file mode 100644
index 00000000000..33af43d6363
--- /dev/null
+++ b/lib/models/MassMail/MassMailPermission.php
@@ -0,0 +1,139 @@
+<?php
+
+namespace MassMail;
+
+class MassMailPermission extends \SimpleORMap
+{
+
+    public const MASSMAIL_ROOT_ROLE = 'Massenmail-Root';
+
+    protected static function configure($config = [])
+    {
+        $config['db_table'] = 'massmail_permissions';
+
+        $config['belongs_to']['institute'] = [
+            'class_name' => \Institute::class,
+            'foreign_key' => 'institute_id',
+            'assoc_foreign_key' => 'institut_id'
+        ];
+
+        $config['has_and_belongs_to_many']['allowed_degrees'] = [
+            'class_name' => \Degree::class,
+            'thru_table' => 'massmail_permission_degree',
+            'thru_key' => 'permission_id',
+            'thru_assoc_key' => 'degree_id',
+            'on_store' => 'store',
+            'on_delete' => 'delete'
+        ];
+
+        $config['has_and_belongs_to_many']['allowed_subjects'] = [
+            'class_name' => \StudyCourse::class,
+            'thru_table' => 'massmail_permission_subject',
+            'thru_key' => 'permission_id',
+            'thru_assoc_key' => 'subject_id',
+            'on_store' => 'store',
+            'on_delete' => 'delete'
+        ];
+
+        $config['has_and_belongs_to_many']['allowed_institutes'] = [
+            'class_name' => \Institute::class,
+            'thru_table' => 'massmail_permission_institute',
+            'thru_key' => 'permission_id',
+            'thru_assoc_key' => 'institute_id',
+            'on_store' => 'store',
+            'on_delete' => 'delete'
+        ];
+
+        $config['additional_fields']['institute_name']['get'] = function($p) {
+            return $p->institute->name;
+        };
+
+        parent::configure($config);
+    }
+
+    /**
+     * Check if the given user has permissions to write mass mails. The result is cached for performance reasons.
+     *
+     * @param string $user_id user to check
+     * @param bool $unrestricted check for unrestricted permissions
+     * @return bool
+     */
+    public static function has(string $user_id, bool $unrestricted = false) : bool
+    {
+        $cached = \Studip\Cache\Factory::getCache()->read('massmail-permission-' . $user_id);
+
+        if ($cached !== false) {
+            $perm = (int) $cached;
+        } else {
+
+            $perm = 0;
+
+            // Root and users with the massmeil root role are always allowed to do anything.
+            if (
+                $GLOBALS['perm']->have_perm('root', $user_id)
+                || \RolePersistence::isAssignedRole($user_id, static::MASSMAIL_ROOT_ROLE)
+            ) {
+                $perm = 2;
+
+                // Everyone else needs at least one institute assignment with existing permissions.
+            } else {
+                // Institute memberships with existing mass mail permission settings.
+                $relevant = static::findBySQL(
+                    "JOIN `user_inst` ON (`user_inst`.`institut_id` = `massmail_permissions`.`institute_id`)
+                    WHERE `user_inst`.`inst_perms` != 'user' AND `user_inst`.`user_id` = :user",
+                    ['user' => $user_id]
+                );
+                foreach ($relevant as $one) {
+                    if ($GLOBALS['perm']->have_studip_perm($one->min_perm, $one->institute_id, $user_id)) {
+                        $perm = 1;
+                        break;
+                    }
+                }
+            }
+
+            \Studip\Cache\Factory::getCache()->write('massmail-permission-' . $user_id, $perm);
+        }
+
+        return $unrestricted ? $perm === 2 : $perm >= 1;
+    }
+
+    /**
+     * @return array{
+     *     allowed_degrees: array,
+     *     allowed_subjects: array,
+     *     allowed_institutes: array
+     * }
+     */
+    public static function getForUser(\User $user, bool $withNames = false): array
+    {
+        // Get user's institutes with at least autor permission.
+        $institutes = $user->institute_memberships->filter(function ($membership) {
+            return in_array($membership->inst_perms, ['autor', 'tutor', 'dozent', 'admin']);
+        })->pluck($withNames ? 'institut_id institute_name' : 'institut_id');
+
+        // Get permission configuration for these institutes.
+        $permissions = static::findBySQL("`institute_id` IN (:institutes)", ['institutes' => $institutes]);
+        $config = [
+            'allowed_degrees' => [],
+            'allowed_subjects' => [],
+            'allowed_institutes' => $institutes
+        ];
+        foreach ($permissions as $permission) {
+            $config['allowed_degrees'] = array_merge(
+                $config['allowed_degrees'],
+                $permission->allowed_degrees->pluck($withNames ? 'id name' : 'id')
+            );
+            $config['allowed_subjects'] = array_merge(
+                $config['allowed_subjects'],
+                $permission->allowed_subjects->pluck($withNames ? 'id name' : 'id')
+            );
+            $config['allowed_institutes'] = array_merge(
+                $config['allowed_institutes'],
+                $permission->allowed_institutes->pluck($withNames ? 'id name' : 'id')
+            );
+        }
+
+        return $config;
+    }
+
+}
diff --git a/lib/models/MassMail/MassMailToken.php b/lib/models/MassMail/MassMailToken.php
new file mode 100644
index 00000000000..aacfe4b64aa
--- /dev/null
+++ b/lib/models/MassMail/MassMailToken.php
@@ -0,0 +1,25 @@
+<?php
+
+namespace MassMail;
+
+class MassMailToken extends \SimpleORMap
+{
+
+    protected static function configure($config = [])
+    {
+        $config['db_table'] = 'massmail_tokens';
+
+        $config['belongs_to']['message'] = [
+            'class_name' => MassMailMessage::class,
+            'foreign_key' => 'message_id'
+        ];
+
+        $config['belongs_to']['user'] = [
+            'class_name' => \User::class,
+            'foreign_key' => 'user_id'
+        ];
+
+        parent::configure($config);
+    }
+
+}
diff --git a/lib/navigation/MessagingNavigation.php b/lib/navigation/MessagingNavigation.php
index 563b7603453..77405a03b07 100644
--- a/lib/navigation/MessagingNavigation.php
+++ b/lib/navigation/MessagingNavigation.php
@@ -31,7 +31,7 @@ class MessagingNavigation extends Navigation
         parent::initItem();
         $my_messaging_settings = UserConfig::get($user->id)->MESSAGING_SETTINGS;
         $lastVisitedTimestamp = isset($my_messaging_settings['last_box_visit'])?(int)$my_messaging_settings['last_box_visit']:0;
-        
+
         $query = "SELECT SUM(mkdate > :time AND readed = 0) AS num_new,
                          SUM(readed = 0) AS num_unread,
                          SUM(readed = 1) AS num_read
@@ -42,7 +42,7 @@ class MessagingNavigation extends Navigation
         $statement->bindValue(':user_id', $GLOBALS['user']->id);
         $statement->execute();
         list($neux, $neum, $altm) = $statement->fetch(PDO::FETCH_NUM);
-        
+
         $this->setBadgeNumber($neum);
 
         if ($neux > 0) {
@@ -69,12 +69,35 @@ class MessagingNavigation extends Navigation
     public function initSubNavigation()
     {
         parent::initSubNavigation();
-        
+
         $messages = new Navigation(_('Nachrichten'), 'dispatch.php/messages/overview');
         $inbox = new Navigation(_('Eingang'), 'dispatch.php/messages/overview');
         $messages->addSubNavigation('inbox', $inbox);
         $messages->addSubNavigation('sent', new Navigation(_('Gesendet'), 'dispatch.php/messages/sent'));
         $this->addSubNavigation('messages', $messages);
-        
+
+        if ($GLOBALS['perm']->have_perm('tutor') && \MassMail\MassMailPermission::has(User::findCurrent()->id)) {
+            $massmail = new Navigation(_('Nachrichten an Zielgruppen'), 'dispatch.php/massmail/message');
+            $massmail->addSubNavigation(
+                'message',
+                new Navigation(_('Nachricht schreiben'), 'dispatch.php/massmail/message')
+            );
+            $massmail->addSubNavigation(
+                'overview',
+                new Navigation(_('Nachrichtenübersicht'), 'dispatch.php/massmail/overview')
+            );
+            if (\MassMail\MassMailPermission::has(User::findCurrent()->id, true)) {
+                $massmail->addSubNavigation(
+                    'permissions',
+                    new Navigation(_('Berechtigungen'), 'dispatch.php/massmail/permissions')
+                );
+                $massmail->addSubNavigation(
+                    'settings',
+                    new Navigation(_('Einstellungen'), 'dispatch.php/massmail/settings')
+                );
+            }
+            $this->addSubNavigation('massmail', $massmail);
+        }
+
     }
 }
diff --git a/resources/vue/base-components.js b/resources/vue/base-components.js
index ec18d59900c..567e9ff484a 100644
--- a/resources/vue/base-components.js
+++ b/resources/vue/base-components.js
@@ -6,12 +6,15 @@ const BaseComponents = {
     Datetimepicker: () => import('./components/Datetimepicker.vue'),
     DayOfWeekSelect: () => import('./components/form_inputs/DayOfWeekSelect.vue'),
     EditableList: () => import("./components/EditableList.vue"),
+    FileUpload: () => import('./components/form_inputs/FileUpload.vue'),
     I18nTextarea: () => import("./components/I18nTextarea.vue"),
     Multiselect: () => import('./components/Multiselect.vue'),
     MyCoursesColouredTable: () => import('./components/form_inputs/MyCoursesColouredTable.vue'),
     Quicksearch: () => import('./components/Quicksearch.vue'),
+    QuicksearchListInput: () => import('./components/form_inputs/QuicksearchListInput.vue'),
     RangeInput: () => import('./components/RangeInput.vue'),
     RepetitionInput: () => import("./components/form_inputs/RepetitionInput.vue"),
+    SerialTextMarkers: () => import('./components/form_inputs/SerialTextMarkers.vue'),
     SidebarWidget: () => import('./components/SidebarWidget.vue'),
     StudipActionMenu: () => import('./components/StudipActionMenu.vue'),
     StudipAssetImg: () => import('./components/StudipAssetImg.vue'),
@@ -27,6 +30,7 @@ const BaseComponents = {
     StudipSelect: () => import('./components/StudipSelect.vue'),
     StudipTooltipIcon: () => import('./components/StudipTooltipIcon.vue'),
     StudipWysiwyg: () => import("./components/StudipWysiwyg.vue"),
+    UserFilterInput: () => import('./components/form_inputs/UserFilterInput.vue')
 };
 
 export default BaseComponents;
diff --git a/resources/vue/components/StudipUserFilter.vue b/resources/vue/components/StudipUserFilter.vue
index f9e6741d171..20ca16201f7 100644
--- a/resources/vue/components/StudipUserFilter.vue
+++ b/resources/vue/components/StudipUserFilter.vue
@@ -65,6 +65,14 @@ export default {
         filter: {
             type: Array,
             default: () => []
+        },
+        context: {
+            type: String,
+            default: ''
+        },
+        target: {
+            type: String,
+            default: ''
         }
     },
     data() {
@@ -119,7 +127,17 @@ export default {
         }
     },
     created() {
-        STUDIP.jsonapi.withPromises().get('user-filter-fields').then(response => {
+        STUDIP.jsonapi.withPromises().get(
+            'user-filter-fields',
+            {
+                data: {
+                    filter: {
+                        context: this.context,
+                        target: this.target
+                    }
+                }
+            }
+        ).then(response => {
             this.availableFields = response.data;
             this.addField();
         });
diff --git a/resources/vue/components/StudipWysiwyg.vue b/resources/vue/components/StudipWysiwyg.vue
index 799c5f1fc29..3b36cc8b111 100644
--- a/resources/vue/components/StudipWysiwyg.vue
+++ b/resources/vue/components/StudipWysiwyg.vue
@@ -59,6 +59,8 @@ export default {
             if (this.shouldFocus) {
                 this.focus();
             }
+
+            STUDIP.eventBus.emit('editor-loaded', this.createdEditor);
         },
         onInput(value) {
             this.currentText = value;
diff --git a/resources/vue/components/form_inputs/FileUpload.vue b/resources/vue/components/form_inputs/FileUpload.vue
new file mode 100644
index 00000000000..b512c026a20
--- /dev/null
+++ b/resources/vue/components/form_inputs/FileUpload.vue
@@ -0,0 +1,198 @@
+<template>
+    <section>
+        <button v-show="!uploading"
+               class="button select"
+               :class="{studiprequired: required}"
+               @click.prevent="openFileSelect">
+            <studip-icon shape="upload"></studip-icon>
+            <span class="textlabel">
+                {{ title }}
+            </span>
+            <span v-if="required"
+                  class="asterisk"
+                  :title="$gettext('Dies ist ein Pflichtfeld')"
+                  aria-hidden="true">*</span>
+        </button>
+        <div class="file-count">
+            <template v-if="selectedFiles?.length === 0">
+                {{ $gettext('Keine Dateien gewählt') }}
+            </template>
+            <template v-else-if="selectedFiles?.length === 1">
+                {{ $gettext('Eine Datei gewählt') }}
+            </template>
+            <template v-else>
+                {{ $gettextInterpolate($gettext('%{number} Dateien gewählt'), { number: selectedFiles.length }) }}
+            </template>
+        </div>
+        <input type="file"
+               :name="name"
+               :id="id"
+               :multiple="multiple"
+               :accept="accept"
+               ref="files"
+               class="button"
+               @change="selectFiles">
+        <button v-if="selectedFiles.length > 0"
+                type="button"
+                class="button upload"
+                @click.prevent="upload">
+            <studip-icon shape="upload"></studip-icon>
+            {{ $gettext('Jetzt hochladen') }}
+        </button>
+        <div v-if="!uploading && uploadedFiles.length > 0">
+            <span>
+                {{ $gettext('Bereits hochgeladen:') }}
+            </span>
+            <ul>
+                <li v-for="(file, index) in uploadedFiles"
+                    :key="index">
+                    {{ file.name + ' (' + getTextualFileSize(file.size) + ')' }}
+                </li>
+            </ul>
+        </div>
+        <input type="hidden"
+               :name="name"
+               :value="targetFolder">
+        <studip-progress-indicator v-if="uploading"
+                                   :size="24">
+            {{ $gettext('Wird hochgeladen...') }}
+        </studip-progress-indicator>
+    </section>
+</template>
+
+<script>
+import axios from 'axios';
+import StudipProgressIndicator from "../StudipProgressIndicator.vue";
+
+export default {
+    name: 'FileUpload',
+    components: {StudipProgressIndicator},
+    props: {
+        name: {
+            type: String,
+            required: true
+        },
+        title: {
+            type: String,
+            required: true
+        },
+        folder: {
+            type: String,
+            required: true
+        },
+        uploadUrl: {
+            type: String,
+            required: true
+        },
+        id: {
+            type: String
+        },
+        required: {
+            type: Boolean,
+            default: false
+        },
+        multiple: {
+            type: Boolean,
+            default: false
+        },
+        accept: {
+            type: String,
+            default: '*/*'
+        }
+    },
+    data() {
+        return {
+            selectedFiles: [],
+            uploading: false,
+            uploadedFiles: [],
+            targetFolder: ''
+        }
+    },
+    methods: {
+        upload() {
+            if (this.$refs.files.files.length > 0) {
+                this.uploading = true;
+
+                const files = this.$refs.files.files;
+
+                const formData = new FormData();
+
+                let name = this.name;
+                if (this.multiple) {
+                    name += '[]';
+                }
+
+                for (let i = 0; i < files.length; i++) {
+                    formData.append(name, files[i]);
+                    this.uploadedFiles.push(files[i]);
+                }
+
+                axios.post(
+                    this.uploadUrl,
+                    formData
+                ).then(response => {
+                    this.uploading = false;
+                    this.$refs.files.value = '';
+                    this.targetFolder = this.folder;
+                }).catch(error => {
+                    this.uploadedFiles = [];
+                    this.uploading = false;
+                    STUDIP.Report.error(this.$gettext('Fehler beim Hochladen'), error);
+                });
+            }
+        },
+        getTextualFileSize(bytes) {
+            let unit = '';
+            let context = {size: bytes};
+            if (bytes < 1024) {
+                unit = this.$gettext('%{size} B');
+            } else if (bytes < 1024 * 1024) {
+                unit = this.$gettext('%{size} KB');
+                context.size = (bytes / 1024).toFixed(2);
+            } else {
+                unit = this.$gettext('%{size} MB');
+                context.size = (bytes / (1024 * 1024)).toFixed(2);
+            }
+
+            return this.$gettextInterpolate(unit, context);
+        },
+        openFileSelect() {
+            this.$refs.files.click();
+        },
+        selectFiles() {
+            this.selectedFiles = this.$refs.files.files;
+        }
+    }
+}
+</script>
+
+<style lang="scss" scoped>
+input[type=file] {
+    display: none;
+}
+button {
+    margin-top: 0;
+    margin-bottom: 0;
+    padding: 5px 10px;
+
+    img {
+        margin-right: 5px;
+        vertical-align: text-bottom;
+    }
+}
+.select {
+    margin-right: 0;
+}
+.file-count {
+    border: solid thin var(--light-gray-color-40);
+    border-left: unset;
+    display: inline-block;
+    margin-left: -4px;
+    padding: 5px 10px 4px 10px;
+    position: relative;
+    top: 2px;
+}
+.upload {
+    margin-left: 15px;
+}
+</style>
diff --git a/resources/vue/components/form_inputs/QuicksearchListInput.vue b/resources/vue/components/form_inputs/QuicksearchListInput.vue
new file mode 100644
index 00000000000..4a3e21c4cd3
--- /dev/null
+++ b/resources/vue/components/form_inputs/QuicksearchListInput.vue
@@ -0,0 +1,97 @@
+<template>
+    <div>
+        <quicksearch :searchtype="searchtype"
+                     :autocomplete="autocomplete"
+                     @input="addElement"></quicksearch>
+        <table v-if="elements.length > 0" ref="results" class="default">
+            <tbody>
+                <tr v-for="(element, index) in elements"
+                    :key="element.id">
+                    <td>
+                        {{ element.name }}
+                    </td>
+                    <td class="actions">
+                        <a @click="removeElement(index)"
+                           :title="$gettext('Dieses Element entfernen')">
+                            <studip-icon shape="trash"></studip-icon>
+                        </a>
+                    </td>
+                </tr>
+            </tbody>
+        </table>
+        <input type="hidden"
+               :name="name"
+               :value="realValue"
+        >
+    </div>
+</template>
+
+<script>
+import quicksearch from '../Quicksearch.vue';
+
+export default {
+    name: 'QuicksearchList',
+    components: [ quicksearch ],
+    props: {
+        name: {
+            type: String,
+            required: true
+        },
+        value: {
+            type: String,
+            default: ''
+        },
+        searchtype: {
+            type: String,
+            required: true
+        },
+        autocomplete: {
+            type: Boolean,
+            default: false
+        }
+    },
+    data() {
+        return {
+            elements: []
+        }
+    },
+    computed: {
+        realValue() {
+            this.$emit('input', JSON.stringify(this.elements));
+            return JSON.stringify(this.elements);
+        }
+    },
+    methods: {
+        addElement(id, name) {
+            if (!this.elements.map(e => e.id).includes(id)) {
+                const element = {
+                    id: id,
+                    name: name
+                };
+                this.elements.push(element);
+            }
+        },
+        removeElement(index) {
+            this.elements.splice(index, 1);
+        }
+    },
+    created() {
+        if (this.value !== '') {
+            this.elements = JSON.parse(this.value);
+        }
+    }
+}
+</script>
+
+<style lang="scss" scoped>
+table.default {
+    margin-bottom: unset;
+    margin-top: 15px;
+    width: 50%;
+
+    .actions {
+        text-align: right;
+    }
+}
+
+</style>
diff --git a/resources/vue/components/form_inputs/SerialTextMarkers.vue b/resources/vue/components/form_inputs/SerialTextMarkers.vue
new file mode 100644
index 00000000000..20e542dab79
--- /dev/null
+++ b/resources/vue/components/form_inputs/SerialTextMarkers.vue
@@ -0,0 +1,80 @@
+<template>
+    <div>
+        <label class="col-3">
+            {{ $gettext('Feld für Serienmail einfügen') }}
+            <select v-model="selectedMarker">
+                <option value="">
+                    -- {{ $gettext('Feld zum Einfügen auswählen') }} --
+                </option>
+                <option v-for="(marker, index) in markers"
+                        :key="index"
+                        :value="marker.marker"
+                        :data-description="marker.description">
+                    {{ marker.name }}
+                </option>
+            </select>
+        </label>
+        <button class="button col-3 insert-marker-button"
+                :title="$gettext('Feld einfügen')"
+                :disabled="selectedMarker === ''"
+                @click.prevent="insertMarker">
+            {{ $gettext('In den Text einfügen') }}
+        </button>
+        <p v-if="selectedMarker !== ''">
+            {{ description }}
+        </p>
+    </div>
+</template>
+
+<script>
+export default {
+    name: 'SerialTextMarkers',
+    props: {
+        markers: {
+            type: Array,
+            required: true
+        },
+        editor: {
+            type: String,
+            required: true
+        }
+    },
+    data() {
+        return {
+            editorInstance: null,
+            selectedMarker: ''
+        }
+    },
+    computed: {
+        description() {
+            return this.markers.find((m) => { return m.marker === this.selectedMarker; }).description;
+        }
+    },
+    methods: {
+        insertMarker() {
+            this.editorInstance.model.change(writer => {
+                writer.insertText(
+                    ' {{' + this.selectedMarker + '}}',
+                    this.editorInstance.model.document.selection.getFirstPosition()
+                );
+            });
+        }
+    },
+    mounted() {
+        STUDIP.eventBus.on('editor-loaded', editor => {
+            if (document.getElementById(this.editor) === editor.sourceElement) {
+                this.editorInstance = editor;
+            }
+        });
+    },
+    destroyed() {
+        STUDIP.eventBus.off('editor-loaded');
+    }
+}
+</script>
+
+<style scoped>
+button {
+    vertical-align: bottom;
+}
+</style>
diff --git a/resources/vue/components/form_inputs/UserFilterInput.vue b/resources/vue/components/form_inputs/UserFilterInput.vue
new file mode 100644
index 00000000000..d63e97b2f22
--- /dev/null
+++ b/resources/vue/components/form_inputs/UserFilterInput.vue
@@ -0,0 +1,146 @@
+<template>
+    <div class="formpart">
+        <section v-if="filters.length > 0" class="default userfilter-list">
+            <header>
+                <h2>
+                    {{ $gettext('Mindestens ein Filter muss zutreffen') }}
+                </h2>
+            </header>
+            <table class="default">
+                <tbody>
+                    <tr v-for="(filter, index) in filters"
+                         :key="index"
+                         class="userfilter">
+                        <td v-html="filter.attributes.text"></td>
+                        <td class="actions">
+                            <a class="undecorated"
+                               @click.prevent="deleteFilter(index)"
+                               :title="$gettext('Diesen Filter löschen')"
+                               tabindex="0">
+                                <studip-icon shape="trash"></studip-icon>
+                            </a>
+                        </td>
+                    </tr>
+                </tbody>
+            </table>
+        </section>
+        <button class="button"
+                type="button"
+                @click.prevent="editFilter(0)">
+            {{ $gettext('Filter hinzufügen') }}
+        </button>
+        <studip-user-filter v-if="currentFilter !== null"
+                            :filter="currentFilter !== 0 ? filters[currentFilter] : []"
+                            :context="context"
+                            :target="target"
+                            @submit="submitFilter"
+                            @close="closeFilter"></studip-user-filter>
+    </div>
+</template>
+
+<script>
+import StudipUserFilter from '../StudipUserFilter.vue';
+
+export default {
+    name: 'UserFilterInput',
+    components: {StudipUserFilter},
+    props: {
+        name: {
+            type: String,
+            required: true
+        },
+        value: String,
+        context: {
+            type: String,
+            default: ''
+        },
+        target: {
+            type: String,
+            default: 'all'
+        }
+    },
+    data() {
+        return {
+            key: 0,
+            currentFilter: null,
+            filters: [],
+            stringified: ''
+        }
+    },
+    methods: {
+        editFilter(index) {
+            this.currentFilter = index;
+        },
+        submitFilter(filter) {
+            STUDIP.jsonapi.withPromises().post(
+                'user-filters',
+                {
+                    data: {
+                        data: {
+                            attributes: {
+                                filters: filter
+                            }
+                        }
+                    }
+                })
+                .then(response => {
+                    if (this.currentFilter !== 0) {
+                        this.filters[this.currentFilter] = response.data;
+                    } else {
+                        this.filters.push(response.data);
+                    }
+                    this.currentFilter = null;
+                    this.changed();
+                })
+                .catch(error => {
+                    STUDIP.Report.error(this.$gettext('Es ist ein Fehler aufgetreten'), error);
+                });
+        },
+        closeFilter() {
+            this.currentFilter = null;
+        },
+        deleteFilter(index) {
+            this.filters.splice(index, 1);
+            this.changed();
+        },
+        actionMenuItems(index) {
+            return [
+                {
+                    id: 'edit',
+                    label: this.$gettext('Bearbeiten'),
+                    icon: 'edit',
+                    emit: 'edit',
+                    emitArguments: index
+                },
+                {
+                    id: 'delete',
+                    label: this.$gettext('Löschen'),
+                    icon: 'trash',
+                    emit: 'delete',
+                    emitArguments: index
+                }
+            ];
+        },
+        changed() {
+            this.stringified = JSON.stringify(this.filters);
+            this.$emit('input', this.stringified);
+        }
+    },
+    mounted() {
+        if (this.value) {
+            this.filters = JSON.parse(this.value);
+        }
+    }
+}
+</script>
+
+<style lang="scss" scoped>
+table.default {
+    margin-bottom: unset;
+    width: 50%;
+
+    .actions {
+        text-align: right;
+    }
+}
+</style>
diff --git a/resources/vue/components/massmail/MassMailMessagesList.vue b/resources/vue/components/massmail/MassMailMessagesList.vue
new file mode 100644
index 00000000000..9d71445e7e0
--- /dev/null
+++ b/resources/vue/components/massmail/MassMailMessagesList.vue
@@ -0,0 +1,153 @@
+<template>
+    <div>
+        <studip-progress-indicator v-if="loading"
+                                   :size="32"
+        />
+        <table v-else-if="messages.data?.length > 0" class="default">
+            <colgroup>
+                <col>
+                <col>
+                <col>
+                <col>
+                <col style="width: 200px">
+                <col style="width: 20px">
+            </colgroup>
+            <thead>
+                <tr>
+                    <th>{{ $gettext('Betreff') }}</th>
+                    <th>{{ $gettext('Nachricht') }}</th>
+                    <th>{{ $gettext('Erstellt von') }}</th>
+                    <th>{{ $gettext('Zielgruppe') }}</th>
+                    <th>{{ $gettext('Letzte Änderung') }}</th>
+                    <th>{{ $gettext('Aktionen') }}</th>
+                </tr>
+            </thead>
+            <tbody>
+                <tr v-for="(message, index) in messages.data"
+                    :key="index"
+                >
+                    <td>{{ message.attributes.subject }}</td>
+                    <td v-html="message.attributes.message"></td>
+                    <td>{{ getAuthor(message.relationships.author.data.id).attributes['formatted-name'] }}</td>
+                    <td>{{ message.attributes.target }}</td>
+                    <td>{{ message.attributes.chdate }}</td>
+                    <td>
+                        <studip-action-menu :items="actionMenuItems"
+                                            @edit="editMessage(message.id)"
+                                            @delete="deleteMessage(message.id)"></studip-action-menu>
+                    </td>
+                </tr>
+            </tbody>
+        </table>
+        <studip-message-box v-else
+                            type="info">
+            {{ $gettext('Es wurden keine Nachrichten gefunden.') }}
+        </studip-message-box>
+        <mounting-portal mount-to="#message-views">
+            <sidebar-widget id="views-widget" class="sidebar-widget" :title="$gettext('Ansichten')">
+                <template #content>
+                    <ul class="widget-list widget-links sidebar-views"
+                        :aria-label="$gettext('Ansichten')">
+                        <li id="index" :class="{ active: 'unsent' === currentView}">
+                            <a :href="url('dispatch.php/massmail/overview')"
+                               @click.prevent="setCurrentView('unsent')">
+                                {{ $gettext('Zum Versand anstehend') }}
+                            </a>
+                        </li>
+                        <li id="index" :class="{ active: 'templates' === currentView}">
+                            <a :href="url('dispatch.php/massmail/overview')"
+                               @click.prevent="setCurrentView('templates')">
+                                {{ $gettext('Meine Vorlagen') }}
+                            </a>
+                        </li>
+                        <li id="index" :class="{ active: 'protected' === currentView}">
+                            <a :href="url('dispatch.php/massmail/overview')"
+                               @click.prevent="setCurrentView('protected')">
+                                {{ $gettext('Geschützt') }}
+                            </a>
+                        </li>
+                    </ul>
+                </template>
+            </sidebar-widget>
+        </mounting-portal>
+    </div>
+</template>
+
+<script>
+import StudipProgressIndicator from '../StudipProgressIndicator.vue';
+import StudipActionMenu from '../StudipActionMenu.vue';
+import SidebarWidget from '../SidebarWidget.vue';
+
+export default {
+    name: 'MassMailMessagesList',
+    components: { SidebarWidget, StudipActionMenu, StudipProgressIndicator },
+    data() {
+        return {
+            loading: false,
+            messages: {},
+            currentView: 'unsent'
+        }
+    },
+    computed: {
+        actionMenuItems() {
+            return [
+                { label: this.$gettext('Bearbeiten'), icon: 'edit', emit: 'edit'},
+                { label: this.$gettext('Löschen'), icon: 'trash', emit: 'delete'}
+            ];
+        }
+    },
+    methods: {
+        getMessages() {
+            this.loading = true;
+
+            const data = { include: 'author'};
+
+            switch (this.currentView) {
+                case 'templates':
+                    data.filter = {templates: 1};
+                    break;
+                case 'protected':
+                    data.filter = {protected: 1};
+                    break;
+            }
+
+            STUDIP.jsonapi.withPromises().get('mass-mails/messages', {data: data})
+                .then(response => {
+                    this.messages = response;
+                    this.loading = false;
+                })
+                .catch(error => {
+                    this.messages = [];
+                    STUDIP.Report.error(this.$gettext('Es ist ein Fehler aufgetreten'), error);
+                    this.loading = false;
+                });
+        },
+        getAuthor(id) {
+            const result = this.messages.included.filter(entry => entry.id === id);
+            return result?.length > 0 ? result[0] : null;
+        },
+        editMessage(id) {
+            window.location = STUDIP.URLHelper.getURL('dispatch.php/massmail/message/index/' + id);
+        },
+        deleteMessage(id) {
+            if (STUDIP.Dialog.confirm(
+                this.$gettext('Soll diese Nachricht wirklich gelöscht werden?'),
+                () => {
+                    window.location = STUDIP.URLHelper.getURL('dispatch.php/massmail/message/delete/' + id);
+                },
+                STUDIP.Dialog.close())
+            );
+        },
+        url(target) {
+            return STUDIP.URLHelper.getURL(target);
+        },
+        setCurrentView(view) {
+            this.currentView = view;
+            this.getMessages();
+        }
+    },
+    created() {
+        this.getMessages();
+    }
+}
+</script>
diff --git a/resources/vue/components/massmail/MassMailPermissions.vue b/resources/vue/components/massmail/MassMailPermissions.vue
new file mode 100644
index 00000000000..6342427f4ee
--- /dev/null
+++ b/resources/vue/components/massmail/MassMailPermissions.vue
@@ -0,0 +1,108 @@
+<template>
+    <div>
+        <table v-if="!loading && permissions?.data.length > 0"
+               class="default">
+            <colgroup>
+                <col>
+                <col width="20%">
+                <col width="30%">
+                <col width="24">
+            </colgroup>
+            <thead>
+            <tr>
+                <th>{{ $gettext('Einrichtung') }}</th>
+                <th>{{ $gettext('Benötigte Rechte') }}</th>
+                <th>{{ $gettext('Erlaubte Zielgruppen') }}</th>
+                <th>{{ $gettext('Aktionen') }}</th>
+            </tr>
+            </thead>
+            <tbody>
+            <tr v-for="(permission) in permissions.data" :key="permission.id">
+                <td>
+                    {{ getInstitute(permission).attributes.name }}
+                </td>
+                <td>
+                    {{ permission.attributes['min-perm']}}
+                </td>
+                <td>
+                    <div v-if="permission.meta['allowed-degrees-count'] > 0">
+                        {{ $gettextInterpolate($gettext('%{degrees} Abschlüsse'), { degrees: permission.meta['allowed-degrees-count']}) }}
+                    </div>
+                    <div v-if="permission.meta['allowed-subjects-count'] > 0">
+                        {{ $gettextInterpolate($gettext('%{subjects} Fächer'), { subjects: permission.meta['allowed-subjects-count']}) }}
+                    </div>
+                    <div v-if="permission.meta['allowed-institutes-count'] > 0">
+                        {{ $gettextInterpolate($gettext('%{institutes} Einrichtungen'), { institutes: permission.meta['allowed-institutes-count']}) }}
+                    </div>
+                </td>
+                <td>
+                    <studip-action-menu :items="actionMenuItems"
+                                        @edit="editPermission(permission.id)"
+                                        @delete="deletePermission(permission.id)"></studip-action-menu>
+                </td>
+            </tr>
+            </tbody>
+        </table>
+        <studip-message-box v-if="!loading && permissions.data.length === 0" type="info">
+            {{ $gettext('Es sind keine Berechtigungen für Personen ohne Root-Rechte konfiguriert.') }}
+        </studip-message-box>
+        <studip-progress-indicator v-if="loading"></studip-progress-indicator>
+    </div>
+</template>
+
+<script>
+import StudipProgressIndicator from "../StudipProgressIndicator.vue";
+import StudipActionMenu from "../StudipActionMenu.vue";
+
+export default {
+    name: 'MassMailPermissions',
+    components: {StudipActionMenu, StudipProgressIndicator},
+    data() {
+        return {
+            loading: true,
+            permissions: []
+        }
+    },
+    computed: {
+        actionMenuItems() {
+            return [
+                { label: this.$gettext('Bearbeiten'), icon: 'edit', emit: 'edit'},
+                { label: this.$gettext('Löschen'), icon: 'trash', emit: 'delete'}
+            ];
+        }
+    },
+    methods: {
+        getInstitute(permission) {
+            const institute = this.permissions.included.filter(entry => {
+                return entry.id === permission.relationships.institute.data.id;
+            });
+            return institute.at(0);
+        },
+        editPermission(id) {
+            STUDIP.Dialog.fromURL(
+                STUDIP.URLHelper.getURL('dispatch.php/massmail/permissions/edit/' + id)
+            );
+        },
+        deletePermission(id) {
+            if (STUDIP.Dialog.confirm(
+                this.$gettext('Soll diese Berechtigung wirklich gelöscht werden?'),
+                () => {
+                    window.location = STUDIP.URLHelper.getURL('dispatch.php/massmail/permissions/delete/' + id);
+                    location.reload();
+                },
+                STUDIP.Dialog.close())
+            );
+        }
+    },
+    created() {
+        STUDIP.jsonapi.GET('mass-mails/permissions', { data: { include: 'institute'}})
+            .then(response => {
+                this.permissions = response;
+                this.loading = false;
+            })
+            .fail(error => {
+                STUDIP.Report.error(error);
+            });
+    }
+}
+</script>
diff --git a/templates/forms/checkbox_collection_input.php b/templates/forms/checkbox_collection_input.php
new file mode 100644
index 00000000000..9e6dec34921
--- /dev/null
+++ b/templates/forms/checkbox_collection_input.php
@@ -0,0 +1,33 @@
+<?php
+/**
+ * @var bool $collapsible
+ * @var string $title
+ * @var array $options
+ * @var bool $required
+ * @var string $name
+ * @var array $selected
+ * @var array $attributes
+ */
+?>
+<fieldset<?= $collapsable ? ' class="collapsable collapsed"' : '' ?>>
+    <legend><?= htmlReady($title) ?></legend>
+    <? foreach ($options as $id => $displayname): ?>
+        <label<?= $required ? ' class="studiprequired"' : '' ?>>
+            <input type="checkbox"
+                   v-model="<?= htmlReady($name) ?>"
+                   name="<?= htmlReady($name) ?>[]"
+                   value="<?= $id ?>"
+                   class="<?= htmlReady($name . '-selector') ?>"
+                   id="<?= $id ?>"
+                <?= $required ? 'required aria-required="true"' : '' ?>
+                <?= in_array($id, $selected) ? 'selected' : '' ?>
+                <?= $attributes ?>>
+            <span class="textlabel">
+                <?= htmlReady($displayname) ?>
+            </span>
+            <? if ($required) : ?>
+                <span class="asterisk" title="<?= _('Dies ist ein Pflichtfeld') ?>" aria-hidden="true">*</span>
+            <? endif ?>
+        </label>
+    <? endforeach ?>
+</fieldset>
diff --git a/templates/forms/fieldset.php b/templates/forms/fieldset.php
index 491f7260ea1..25a9739396b 100644
--- a/templates/forms/fieldset.php
+++ b/templates/forms/fieldset.php
@@ -1,4 +1,12 @@
-<fieldset>
+<?php
+/**
+ * @var bool $collapsable
+ * @var bool $collapsed
+ * @var string $legend
+ * @var array<\Studip\Forms\Part> $part
+ */
+?>
+<fieldset<?= $collapsable ? ' class="collapsable' . ($collapsed ? ' collapsed' : '') . '"' : '' ?>>
     <? if ($legend) : ?>
         <legend><?= htmlReady($this->legend) ?></legend>
     <? endif ?>
diff --git a/templates/forms/file_input.php b/templates/forms/file_input.php
new file mode 100644
index 00000000000..2441b522cdf
--- /dev/null
+++ b/templates/forms/file_input.php
@@ -0,0 +1,11 @@
+<div class="formpart" data-form-input-for="<?= htmlReady($name) ?>">
+    <file-upload
+        name="<?= htmlReady($name) ?>"
+        title="<?= htmlReady($title) ?>"
+        upload-url="<?= htmlReady($uploadUrl) ?>"
+        folder="<?= htmlReady($value) ?>"
+        id="<?= htmlReady($id) ?>"
+        :multiple="<?= $multiple ? 'true' : 'false' ?>"
+        accept="<?= htmlReady($accept) ?>"
+        <?= $required ? ':required="true"' : '' ?>></file-upload>
+</div>
diff --git a/templates/forms/form.php b/templates/forms/form.php
index b3679740825..00e4c7df6d4 100644
--- a/templates/forms/form.php
+++ b/templates/forms/form.php
@@ -37,6 +37,7 @@ $form_id = md5(uniqid());
       data-required="<?= htmlReady(json_encode($required_inputs)) ?>"
       data-server_validation="<?= $server_validation ? 1 : 0?>"
       data-validation_url="<?= htmlReady($_SERVER['REQUEST_URI']) ?>"
+      <?= $form->hasFileInput() ? ' enctype="application/x-www-form-urlencoded"' : '' ?>
       class="default studipform<?= $form->isCollapsable() ? ' collapsable' : '' ?>">
 
     <?= CSRFProtection::tokenTag(['ref' => 'securityToken']) ?>
diff --git a/templates/forms/quicksearchlist_input.php b/templates/forms/quicksearchlist_input.php
new file mode 100644
index 00000000000..167ec938cb8
--- /dev/null
+++ b/templates/forms/quicksearchlist_input.php
@@ -0,0 +1,18 @@
+<div class="formpart" data-form-input-for="<?= htmlReady($name) ?>">
+    <label<?= $required ? ' class="studiprequired"' : '' ?> for="<?= htmlReady($id) ?>">
+        <span class="textlabel">
+            <?= htmlReady($title) ?>
+        </span>
+        <? if ($required) : ?>
+            <span class="asterisk" title="<?= _('Dies ist ein Pflichtfeld') ?>" aria-hidden="true">*</span>
+        <? endif ?>
+        <quicksearch-list-input
+            name="<?= htmlReady($name) ?>"
+            id="<?= htmlReady($id) ?>"
+            <?= $required ? 'required aria-required="true"' : '' ?>
+            value="<?= htmlReady($value) ?>"
+            v-model="<?= htmlReady($name) ?>"
+            <?= $attributes ?>
+        ></quicksearch-list-input>
+    </label>
+</div>
diff --git a/templates/forms/radio_input.php b/templates/forms/radio_input.php
index da110d1f830..f7636d19cf6 100644
--- a/templates/forms/radio_input.php
+++ b/templates/forms/radio_input.php
@@ -1,17 +1,21 @@
 <div class="formpart">
     <section <?= $this->orientation == 'horizontal' ? 'class="hgroup"' : '' ?> id="<?= htmlReady($id) ?>">
-    <span class="textlabel">
+    <span class="textlabel<?= $required ? ' studiprequired' : '' ?> ">
         <?= htmlReady($this->title) ?>
+        <? if ($required) : ?>
+            <span class="asterisk" title="<?= _('Dies ist ein Pflichtfeld') ?>" aria-hidden="true">*</span>
+        <? endif ?>
     </span>
 
-    <? foreach ($options as $key => $option) : ?>
+    <? $count = 0; foreach ($options as $key => $option) : ?>
         <label class="" <?= $attributes ?>>
                 <input type="radio"
-                       name="<?= htmlReady($this->name) ?>"
-                       v-model="<?= htmlReady($this->name) ?>"
-                       value="<?= htmlReady($key) ?>" <?= $key == $value ? 'checked' : '' ?>>
+                       name="<?= htmlReady($name) ?>"
+                       v-model="<?= htmlReady($name) ?>"
+                       value="<?= htmlReady($key) ?>" <?= $key == $value ? 'checked' : '' ?>
+                       <?= $required && $count === 0 ? ' required' : ''?>>
                     <?= htmlReady($option) ?>
         </label>
-    <? endforeach ?>
+    <? $count++; endforeach ?>
 </section>
 </div>
diff --git a/templates/forms/serial_wysiwyg_input.php b/templates/forms/serial_wysiwyg_input.php
new file mode 100644
index 00000000000..f67a0c14f45
--- /dev/null
+++ b/templates/forms/serial_wysiwyg_input.php
@@ -0,0 +1,17 @@
+<div class="formpart" data-form-input-for="<?= htmlReady($name) ?>">
+    <label<?= $this->required ? ' class="studiprequired"' : '' ?> for="<?= htmlReady($id) ?>">
+        <span class="textlabel">
+            <?= htmlReady($this->title) ?>
+        </span>
+        <? if ($this->required) : ?>
+            <span class="asterisk" title="<?= _('Dies ist ein Pflichtfeld') ?>" aria-hidden="true">*</span>
+        <? endif ?>
+    </label>
+    <serial-text-markers :markers="<?= htmlReady($markers) ?>" editor="<?= htmlReady($id) ?>"></serial-text-markers>
+    <studip-wysiwyg
+        id="<?= htmlReady($id) ?>"
+        v-model="<?= htmlReady($name) ?>"
+        value="<?= htmlReady($value) ?>"
+        <?= $required ? 'required' : '' ?>>
+    </studip-wysiwyg>
+</div>
diff --git a/templates/forms/textarea_input.php b/templates/forms/textarea_input.php
index 2afa96c3810..8effff1fa46 100644
--- a/templates/forms/textarea_input.php
+++ b/templates/forms/textarea_input.php
@@ -1,13 +1,15 @@
-<label<?= ($this->required ? ' class="studiprequired"' : '') ?> for="<?= $id ?>">
-    <span class="textlabel">
-        <?= htmlReady($this->title) ?>
-    </span>
-    <? if ($this->required) : ?>
-        <span class="asterisk" title="<?= _('Dies ist ein Pflichtfeld') ?>" aria-hidden="true">*</span>
-    <? endif ?>
-</label>
-<textarea name="<?= htmlReady($name) ?>"
-          v-model="<?= htmlReady($name) ?>"
-          id="<?= $id ?>"
-          <?= ($required ? 'required aria-required="true"' : '') ?>
-          <?= $attributes ?>><?= htmlReady($value) ?></textarea>
+<div class="formpart" data-form-input-for="<?= htmlReady($name) ?>">
+    <label<?= ($this->required ? ' class="studiprequired"' : '') ?> for="<?= $id ?>">
+        <span class="textlabel">
+            <?= htmlReady($this->title) ?>
+        </span>
+        <? if ($this->required) : ?>
+            <span class="asterisk" title="<?= _('Dies ist ein Pflichtfeld') ?>" aria-hidden="true">*</span>
+        <? endif ?>
+    </label>
+    <textarea name="<?= htmlReady($name) ?>"
+              v-model="<?= htmlReady($name) ?>"
+              id="<?= $id ?>"
+              <?= ($required ? 'required aria-required="true"' : '') ?>
+              <?= $attributes ?>><?= htmlReady($value) ?></textarea>
+</div>
diff --git a/templates/forms/user_filter_input.php b/templates/forms/user_filter_input.php
new file mode 100644
index 00000000000..20d9386a63b
--- /dev/null
+++ b/templates/forms/user_filter_input.php
@@ -0,0 +1,19 @@
+<div class="formpart" data-form-input-for="<?= htmlReady($name) ?>">
+    <label<?= $required ? ' class="studiprequired"' : '' ?> for="<?= htmlReady($id) ?>">
+        <span class="textlabel">
+            <?= htmlReady($title) ?>
+        </span>
+        <? if ($required) : ?>
+            <span class="asterisk" title="<?= _('Dies ist ein Pflichtfeld') ?>" aria-hidden="true">*</span>
+        <? endif ?>
+        <user-filter-input
+            name="<?= htmlReady($name) ?>"
+            id="<?= htmlReady($id) ?>"
+            <?= $required ? 'required aria-required="true"' : '' ?>
+            value="<?= htmlReady($value) ?>"
+            v-model="<?= htmlReady($name) ?>"
+            <?= $attributes ?>
+        ></user-filter-input>
+    </label>
+
+</div>
-- 
GitLab