diff --git a/website/thaliawebsite/forms.py b/website/thaliawebsite/forms.py index ee71822ad..fa74686ea 100644 --- a/website/thaliawebsite/forms.py +++ b/website/thaliawebsite/forms.py @@ -1,5 +1,10 @@ """Special forms.""" +from django import forms +from django.conf import settings from django.contrib.auth.forms import AuthenticationForm as BaseAuthenticationForm +from django.utils.translation import gettext_lazy as _ + +from utils.snippets import send_email class AuthenticationForm(BaseAuthenticationForm): @@ -10,3 +15,87 @@ def clean(self): if "username" in self.cleaned_data: self.cleaned_data["username"] = self.cleaned_data["username"].lower() super().clean() + + +class EmailSenderForm(forms.Form): + recipients = forms.CharField( + label=_("Recipients"), + help_text=_("Enter multiple email addresses separated by commas."), + required=True, + ) + cc_recipients = forms.CharField( + label=_("CC recipients (optional)"), + help_text=_("Enter multiple email addresses separated by commas."), + required=False, + ) + bcc_recipients = forms.CharField( + label=_("BCC recipients (optional)"), + help_text=_( + "Enter multiple email addresses separated by commas. Emails will always be sent to %s as well." + ) + % settings.DEFAULT_FROM_EMAIL, + required=False, + ) + + reply_to = forms.CharField( + label=_("Reply to-addresses (optional)"), + help_text=_( + "Enter multiple email addresses separated by commas. Will be set as reply-to email address. " + "Emails will always be sent from %s." + ) + % settings.DEFAULT_FROM_EMAIL, + required=False, + ) + + subject = forms.CharField(label=_("Subject")) + message = forms.CharField( + label=_("Message body"), + help_text=_("Supports or simple HTML (but be careful with that)."), + widget=forms.Textarea, + ) + + def clean_recipients(self): + return self.email_address_cleaner("recipients") + + def clean_bcc_recipients(self): + return self.email_address_cleaner("bcc_recipients") + + def clean_cc_recipients(self): + return self.email_address_cleaner("cc_recipients") + + def clean_reply_to(self): + return self.email_address_cleaner("reply_to") + + def email_address_cleaner(self, field_name): + recipients = self.cleaned_data[field_name].split(",") + recipients = [r.strip() for r in recipients] + + if recipients == [""]: + return None + + # validate that all recipients are valid email addresses + for recipient in recipients: + if not forms.EmailField().clean(recipient): + raise forms.ValidationError( + _("Invalid email address: %(email)s"), params={"email": recipient} + ) + + return recipients + + def send(self): + txt_template = "email/email_sender_email.txt" + html_template = "email/email_sender_email.html" + + bcc = self.cleaned_data["bcc_recipients"] or [] + bcc += settings.DEFAULT_FROM_EMAIL + + send_email( + to=self.cleaned_data["recipients"], + subject=self.cleaned_data["subject"], + txt_template=txt_template, + context={"message": self.cleaned_data["message"]}, + html_template=html_template, + bcc=bcc, + cc=self.cleaned_data["cc_recipients"], + reply_to=self.cleaned_data["reply_to"], + ) diff --git a/website/thaliawebsite/migrations/0001_initial.py b/website/thaliawebsite/migrations/0001_initial.py new file mode 100644 index 000000000..2bc1d9f0e --- /dev/null +++ b/website/thaliawebsite/migrations/0001_initial.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.1 on 2023-10-02 18:47 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [] + + operations = [ + migrations.CreateModel( + name="FunctionalPermissions", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ], + options={ + "permissions": ( + ("email_sender", "Send emails using the email sender"), + ), + "managed": False, + "default_permissions": (), + }, + ), + ] diff --git a/website/thaliawebsite/models.py b/website/thaliawebsite/models.py new file mode 100644 index 000000000..f3159b5a8 --- /dev/null +++ b/website/thaliawebsite/models.py @@ -0,0 +1,13 @@ +from django.db import models + + +class FunctionalPermissions(models.Model): + """Custom auxiliary model to define functional non-model permissions.""" + + class Meta: + managed = False # Don't create a table for this model + default_permissions = () # disable "add", "change", "delete" and "view" default permissions + permissions = (("email_sender", "Send emails using the email sender"),) + + def __str__(self): + return "Functional permissions" diff --git a/website/thaliawebsite/templates/base.html b/website/thaliawebsite/templates/base.html index 7ce657f7a..432e186b2 100644 --- a/website/thaliawebsite/templates/base.html +++ b/website/thaliawebsite/templates/base.html @@ -188,6 +188,14 @@ {% trans "Site administration" %} + {% if perms.thaliawebsite.email_sender %} +
  • + + {% trans "Email sender" %} + +
  • + {% endif %} {% endif %}

  • + Study Association Thalia +
    +{% endblock %} +{% block disclaimer %}{% endblock %} diff --git a/website/thaliawebsite/templates/email/email_sender_email.txt b/website/thaliawebsite/templates/email/email_sender_email.txt new file mode 100644 index 000000000..a4eb724dc --- /dev/null +++ b/website/thaliawebsite/templates/email/email_sender_email.txt @@ -0,0 +1,4 @@ +{{ message|striptags }} + +With kind regards, +Study Association Thalia diff --git a/website/thaliawebsite/templates/email/html_email.html b/website/thaliawebsite/templates/email/html_email.html index eab88847c..bee8f1332 100644 --- a/website/thaliawebsite/templates/email/html_email.html +++ b/website/thaliawebsite/templates/email/html_email.html @@ -20,6 +20,8 @@ {% endblock %} + {% block disclaimer %}

    This email was automatically generated.

    + {% endblock %} diff --git a/website/thaliawebsite/templates/email_sender.html b/website/thaliawebsite/templates/email_sender.html new file mode 100644 index 000000000..4300bd4d3 --- /dev/null +++ b/website/thaliawebsite/templates/email_sender.html @@ -0,0 +1,17 @@ +{% extends "small_page.html" %} +{% load i18n static django_bootstrap5 alert %} + +{% block title %}{% trans "Send email" %} — {{ block.super }}{% endblock %} +{% block opengraph_title %}{% trans "Send email" %} — {{ block.super }}{% endblock %} + +{% block page_title %}{% trans "Send email" %}{% endblock %} + +{% block page_content %} +
    + {% csrf_token %} + {% for field in form %} + {% bootstrap_field field %} + {% endfor %} + +
    +{% endblock %} diff --git a/website/thaliawebsite/urls.py b/website/thaliawebsite/urls.py index dec503528..63c09772f 100644 --- a/website/thaliawebsite/urls.py +++ b/website/thaliawebsite/urls.py @@ -53,6 +53,7 @@ from thabloid.sitemaps import sitemap as thabloid_sitemap from thaliawebsite.forms import AuthenticationForm from thaliawebsite.views import ( + EmailSenderView, IndexView, RateLimitedLoginView, RateLimitedPasswordResetView, @@ -160,6 +161,7 @@ ] ), ), + path("send-email/", EmailSenderView.as_view(), name="email-sender"), # Apps path("", include("singlepages.urls")), path("", include("merchandise.urls")), diff --git a/website/thaliawebsite/views.py b/website/thaliawebsite/views.py index 733b67866..130d3796c 100644 --- a/website/thaliawebsite/views.py +++ b/website/thaliawebsite/views.py @@ -1,16 +1,21 @@ """General views for the website.""" - +from django.contrib import messages from django.contrib.admin.views.decorators import staff_member_required +from django.contrib.auth.decorators import permission_required from django.contrib.auth.views import LoginView, PasswordResetView from django.core.exceptions import PermissionDenied from django.http import HttpResponse, HttpResponseForbidden from django.shortcuts import redirect +from django.urls import reverse_lazy from django.utils.decorators import method_decorator -from django.views.generic import ListView, TemplateView +from django.utils.translation import gettext_lazy as _ +from django.views.generic import FormView, ListView, TemplateView from django.views.generic.base import View from django_ratelimit.decorators import ratelimit +from thaliawebsite.forms import EmailSenderForm + class IndexView(TemplateView): template_name = "index.html" @@ -85,3 +90,19 @@ def admin_unauthorized_view(request): raise PermissionDenied("You are not allowed to access the administration page.") else: return redirect(request.GET.get("next", "/")) + + +@method_decorator(staff_member_required, name="dispatch") +@method_decorator( + permission_required("thaliawebsite.email_sender"), + name="dispatch", +) +class EmailSenderView(FormView): + template_name = "email_sender.html" + form_class = EmailSenderForm + success_url = reverse_lazy("send-email") + + def form_valid(self, form): + form.send() + messages.success(self.request, _("Email sent successfully.")) + return super().form_valid(form) diff --git a/website/utils/snippets.py b/website/utils/snippets.py index bc7b1374c..4bf4cc087 100644 --- a/website/utils/snippets.py +++ b/website/utils/snippets.py @@ -211,6 +211,8 @@ def send_email( context: dict, html_template: Optional[str] = None, bcc: Optional[list[str]] = None, + cc: Optional[list[str]] = None, + reply_to: Optional[list[str]] = None, from_email: Optional[str] = None, connection=None, ) -> None: @@ -221,6 +223,8 @@ def send_email( body=txt_message, to=to, bcc=bcc, + cc=cc, + reply_to=reply_to, from_email=from_email, connection=connection, )