diff --git a/aleksis/core/migrations/0009_rename_fields_notification_activity.py b/aleksis/core/migrations/0009_rename_fields_notification_activity.py
new file mode 100644
index 0000000000000000000000000000000000000000..f38db142f83152cbd27872d1221a7d9f881207ba
--- /dev/null
+++ b/aleksis/core/migrations/0009_rename_fields_notification_activity.py
@@ -0,0 +1,39 @@
+# Generated by Django 3.0.2 on 2020-01-22 16:49
+
+import django.contrib.postgres.fields.jsonb
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('core', '0008_extended_data'),
+    ]
+
+    operations = [
+        migrations.RenameField(
+            model_name='notification',
+            old_name='user',
+            new_name='recipient',
+        ),
+        migrations.RenameField(
+            model_name='notification',
+            old_name='app',
+            new_name='sender',
+        ),
+        migrations.RenameField(
+            model_name='notification',
+            old_name='mailed',
+            new_name='sent',
+        ),
+        migrations.AlterField(
+            model_name='activity',
+            name='created_at',
+            field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'),
+        ),
+        migrations.AlterField(
+            model_name='notification',
+            name='created_at',
+            field=models.DateTimeField(auto_now_add=True, verbose_name='Created at'),
+        ),
+    ]
diff --git a/aleksis/core/models.py b/aleksis/core/models.py
index 6b712780bff8dac1c1551f5782ded576b4761898..eceeaa1e1687724b0a929a68dd8f50b30690543a 100644
--- a/aleksis/core/models.py
+++ b/aleksis/core/models.py
@@ -3,14 +3,12 @@ from typing import Optional
 from django.contrib.auth import get_user_model
 from django.contrib.auth.models import User
 from django.db import models
-from django.utils import timezone
 from django.utils.translation import ugettext_lazy as _
 from image_cropping import ImageCropField, ImageRatioField
 from phonenumber_field.modelfields import PhoneNumberField
 
-from .mailer import send_mail_with_template
-from templated_email import send_templated_mail
 from .mixins import ExtensibleModel
+from .util.notifications import send_notification
 
 from constance import config
 
@@ -195,7 +193,7 @@ class Activity(models.Model):
 
     app = models.CharField(max_length=100, verbose_name=_("Application"))
 
-    created_at = models.DateTimeField(default=timezone.now, verbose_name=_("Created at"))
+    created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at"))
 
     def __str__(self):
         return self.title
@@ -206,37 +204,26 @@ class Activity(models.Model):
 
 
 class Notification(models.Model):
-    user = models.ForeignKey("Person", on_delete=models.CASCADE, related_name="notifications")
+    sender = models.CharField(max_length=100, verbose_name=_("Sender"))
+    recipient = models.ForeignKey("Person", on_delete=models.CASCADE, related_name="notifications")
+
     title = models.CharField(max_length=150, verbose_name=_("Title"))
     description = models.TextField(max_length=500, verbose_name=_("Description"))
     link = models.URLField(blank=True, verbose_name=_("Link"))
 
-    app = models.CharField(max_length=100, verbose_name=_("Application"))
-
     read = models.BooleanField(default=False, verbose_name=_("Read"))
-    mailed = models.BooleanField(default=False, verbose_name=_("Mailed"))
-    created_at = models.DateTimeField(default=timezone.now, verbose_name=_("Created at"))
+    sent = models.BooleanField(default=False, verbose_name=_("Sent"))
+
+    created_at = models.DateTimeField(auto_now_add=True, verbose_name=_("Created at"))
 
     def __str__(self):
         return self.title
 
     def save(self, **kwargs):
+        send_notification(self)
+        self.sent = True
         super().save(**kwargs)
 
-        if not self.mailed:
-            context = {
-                "notification": self,
-                "notification_user": self.user.adressing_name,
-            }
-            send_templated_mail(
-                template_name='notification',
-                from_email=config.MAIL_OUT,
-                recipient_list=[self.user.email],
-                context=context,
-            )
-            self.mailed = True
-            super().save(**kwargs)
-
     class Meta:
         verbose_name = _("Notification")
         verbose_name_plural = _("Notifications")
diff --git a/aleksis/core/settings.py b/aleksis/core/settings.py
index 8b0f6bcd271f599026d7bb43ea5d966f4e407a67..46b070449c3bc6ff46e98ace323953b7f6252ac1 100644
--- a/aleksis/core/settings.py
+++ b/aleksis/core/settings.py
@@ -9,6 +9,7 @@ from dynaconf import LazySettings
 from easy_thumbnails.conf import Settings as thumbnail_settings
 
 from .util.core_helpers import get_app_packages, lazy_config, merge_app_settings
+from .util.notifications import get_notification_choices_lazy
 
 ENVVAR_PREFIX_FOR_DYNACONF = "ALEKSIS"
 DIRS_FOR_DYNACONF = ["/etc/aleksis"]
@@ -320,6 +321,10 @@ CONSTANCE_ADDITIONAL_FIELDS = {
                     ("dutch", "Doe John"),
                     )
     }],
+    "notifications-select": ["django.forms.fields.MultipleChoiceField", {
+        "widget": "django.forms.CheckboxSelectMultiple",
+        "choices": get_notification_choices_lazy,
+    }],
     "weekday_field": ["django.forms.fields.ChoiceField", {
         'widget': 'django.forms.Select',
         "choices":  i18n_day_name_choices_lazy
@@ -330,15 +335,17 @@ CONSTANCE_CONFIG = {
     "COLOUR_PRIMARY": ("#007bff", _("Primary colour")),
     "COLOUR_SECONDARY": ("#007bff", _("Secondary colour")),
     "MAIL_OUT_NAME": ("AlekSIS", _("Mail out name")),
-    "MAIL_OUT": ("aleksis@example.com", _("Mail out address"), "email_field"),
+    "MAIL_OUT": (DEFAULT_FROM_EMAIL, _("Mail out address"), "email_field"),
     "PRIVACY_URL": ("", _("Link to privacy policy"), "url_field"),
     "IMPRINT_URL": ("", _("Link to imprint"), "url_field"),
-    "ADRESSING_NAME_FORMAT": ("german", _("Name format of adresses"), "adressing-select")
+    "ADRESSING_NAME_FORMAT": ("german", _("Name format of adresses"), "adressing-select"),
+    "NOTIFICATION_CHANNELS": (["email"], _("Channels to allow for notifications"), "notifications-select"),
 }
 CONSTANCE_CONFIG_FIELDSETS = {
     "General settings": ("SITE_TITLE",),
     "Theme settings": ("COLOUR_PRIMARY", "COLOUR_SECONDARY"),
-    "Mail settings": ("MAIL_OUT_NAME", "MAIL_OUT", "ADRESSING_NAME_FORMAT"),
+    "Mail settings": ("MAIL_OUT_NAME", "MAIL_OUT"),
+    "Notification settings": ("NOTIFICATION_CHANNELS", "ADRESSING_NAME_FORMAT"),
     "Footer settings": ("PRIVACY_URL", "IMPRINT_URL"),
 }
 
@@ -364,19 +371,25 @@ ANONYMIZE_ENABLED = _settings.get("maintenance.anonymisable", True)
 LOGIN_URL = "two_factor:login"
 
 if _settings.get("2fa.call.enabled", False):
+    if "two_factor.middleware.threadlocals.ThreadLocals" not in MIDDLEWARE:
+        MIDDLEWARE.insert(
+            MIDDLEWARE.index("django_otp.middleware.OTPMiddleware") + 1,
+            "two_factor.middleware.threadlocals.ThreadLocals",
+        )
     TWO_FACTOR_CALL_GATEWAY = "two_factor.gateways.twilio.gateway.Twilio"
 
 if _settings.get("2fa.sms.enabled", False):
+    if "two_factor.middleware.threadlocals.ThreadLocals" not in MIDDLEWARE:
+        MIDDLEWARE.insert(
+            MIDDLEWARE.index("django_otp.middleware.OTPMiddleware") + 1,
+            "two_factor.middleware.threadlocals.ThreadLocals",
+        )
     TWO_FACTOR_SMS_GATEWAY = "two_factor.gateways.twilio.gateway.Twilio"
 
-if _settings.get("2fa.twilio.sid", None):
-    MIDDLEWARE.insert(
-        MIDDLEWARE.index("django_otp.middleware.OTPMiddleware") + 1,
-        "two_factor.middleware.threadlocals.ThreadLocals",
-    )
-    TWILIO_SID = _settings.get("2fa.twilio.sid")
-    TWILIO_TOKEN = _settings.get("2fa.twilio.token")
-    TWILIO_CALLER_ID = _settings.get("2fa.twilio.callerid")
+if _settings.get("twilio.sid", None):
+    TWILIO_SID = _settings.get("twilio.sid")
+    TWILIO_TOKEN = _settings.get("twilio.token")
+    TWILIO_CALLER_ID = _settings.get("twilio.callerid")
 
 if _settings.get("celery.enabled", False):
     INSTALLED_APPS += ("django_celery_beat", "django_celery_results")
diff --git a/aleksis/core/templates/sms/notification.txt b/aleksis/core/templates/sms/notification.txt
new file mode 100644
index 0000000000000000000000000000000000000000..db06b797ee8b649a9357fa287591f802177af054
--- /dev/null
+++ b/aleksis/core/templates/sms/notification.txt
@@ -0,0 +1,6 @@
+{% load i18n %}
+🔔 {{ notification.title }}
+
+{{ notification.description }}
+
+{{ notification.sender }}{% if notification.link %} · {% endif %}{{ notification.link }}
diff --git a/aleksis/core/templates/templated_email/notification.email b/aleksis/core/templates/templated_email/notification.email
index 4f8fc26fccfc939acddfdf080e30a40ec956b3fc..bb39f5861876b1dc1c4c39639de649ec6d6590eb 100644
--- a/aleksis/core/templates/templated_email/notification.email
+++ b/aleksis/core/templates/templated_email/notification.email
@@ -13,8 +13,8 @@
         {% endif %}
     </blockquote>
 
-    {% blocktrans with trans_app=notification.app trans_created_at=notification.created_at %}
-    <p>By {{ trans_app }} at {{ trans_created_at }}</p>
+    {% blocktrans with trans_sender=notification.sender trans_created_at=notification.created_at %}
+    <p>By {{ trans_sender }} at {{ trans_created_at }}</p>
 
     <i>Your AlekSIS team</i>
     {% endblocktrans %}
diff --git a/aleksis/core/util/core_helpers.py b/aleksis/core/util/core_helpers.py
index d48564c1865371070bfe7ffbf7d1d272d191fa4b..bc0700b03b8c755220b5bedfd609aa5d810a86d1 100644
--- a/aleksis/core/util/core_helpers.py
+++ b/aleksis/core/util/core_helpers.py
@@ -114,15 +114,16 @@ def celery_optional(orig: Callable) -> Callable:
     and it is executed synchronously.
     """
 
-    if hasattr(settings, "CELERY_RESULT_BACKEND"):
-        from ..celery import app  # noqa
-        task = app.task(orig)
+    def wrapped(*args, **kwargs):
+        if hasattr(settings, "CELERY_RESULT_BACKEND"):
+            from ..celery import app  # noqa
+            task = app.task(orig)
 
-        def wrapped(*args, **kwargs):
             task.delay(*args, **kwargs)
-        return wrapped
-    else:
-        return orig
+        else:
+            orig(*args, **kwargs)
+
+    return wrapped
 
 
 def path_and_rename(instance, filename: str, upload_to: str = "files") -> str:
diff --git a/aleksis/core/util/notifications.py b/aleksis/core/util/notifications.py
new file mode 100644
index 0000000000000000000000000000000000000000..5f6f8fded9a00323b2695e27268d4d4a744c0525
--- /dev/null
+++ b/aleksis/core/util/notifications.py
@@ -0,0 +1,107 @@
+""" Utility code for notification system """
+
+from typing import Sequence, Union
+
+from django.apps import apps
+from django.conf import settings
+from django.template.loader import get_template
+from django.utils.functional import lazy
+from django.utils.translation import gettext_lazy as _
+
+from templated_email import send_templated_mail
+
+try:
+    from twilio.rest import Client as TwilioClient
+except ImportError:
+    TwilioClient = None
+
+from .core_helpers import celery_optional, lazy_config
+
+
+def send_templated_sms(
+    template_name: str, from_number: str, recipient_list: Sequence[str], context: dict
+) -> None:
+    """ Render a plan-text template and send via SMS to all recipients. """
+
+    template = get_template(template_name)
+    text = template.render(context)
+
+    client = TwilioClient(settings.TWILIO_SID, settings.TWILIO_TOKEN)
+    for recipient in recipient_list:
+        client.messages.create(body=text, to=recipient, from_=from_number)
+
+
+def _send_notification_email(notification: "Notification", template: str = "notification") -> None:
+    context = {
+        "notification": notification,
+        "notification_user": notification.recipient.adressing_name,
+    }
+    send_templated_mail(
+        template_name=template,
+        from_email=lazy_config("MAIL_OUT"),
+        recipient_list=[notification.recipient.email],
+        context=context,
+    )
+
+
+def _send_notification_sms(
+    notification: "Notification", template: str = "sms/notification.txt"
+) -> None:
+    context = {
+        "notification": notification,
+        "notification_user": notification.recipient.adressing_name,
+    }
+    send_templated_sms(
+        template_name=template,
+        from_number=settings.TWILIO_CALLER_ID,
+        recipient_list=[notification.recipient.mobile_number.as_e164],
+        context=context,
+    )
+
+
+# Mapping of channel id to name and two functions:
+# - Check for availability
+# - Send notification through it
+_CHANNELS_MAP = {
+    "email": (_("E-Mail"), lambda: lazy_config("MAIL_OUT"), _send_notification_email),
+    "sms": (_("SMS"), lambda: getattr(settings, "TWILIO_SID", None), _send_notification_sms),
+}
+
+
+@celery_optional
+def send_notification(notification: Union[int, "Notification"], resend: bool = False) -> None:
+    """ Send a notification through enabled channels.
+
+    If resend is passed as True, the notification is sent even if it was
+    previously marked as sent.
+    """
+
+    channels = lazy_config("NOTIFICATION_CHANNELS")
+
+    if isinstance(notification, int):
+        Notification = apps.get_model("core", "Notification")
+        notification = Notification.objects.get(pk=notification)
+
+    if resend or not notification.sent:
+        for channel in channels:
+            name, check, send = _CHANNELS_MAP[channel]
+            if check():
+                send(notification)
+
+
+def get_notification_choices() -> list:
+    """ Return all available channels for notifications.
+
+    This gathers the channels that are technically available as per the
+    system configuration. Which ones are available to users is defined
+    by the administrator (by selecting a subset of these choices).
+    """
+
+    choices = []
+    for channel, (name, check, send) in _CHANNELS_MAP.items():
+        if check():
+            choices.append((channel, name))
+    return choices
+
+
+get_notification_choices_lazy = lazy(get_notification_choices, tuple)
diff --git a/aleksis/core/views.py b/aleksis/core/views.py
index a320980e51657381d2e358a4314a6ef1e1af7ae9..ff11482f68268d5f5d090502039d1c4be12e99a2 100644
--- a/aleksis/core/views.py
+++ b/aleksis/core/views.py
@@ -255,7 +255,7 @@ def notification_mark_read(request: HttpRequest, id_: int) -> HttpResponse:
 
     notification = get_object_or_404(Notification, pk=id_)
 
-    if notification.user == request.user:
+    if notification.recipient.user == request.user:
         notification.read = True
         notification.save()
     else: