From 93e80284130b461874f8b0599a93fed854baab1f Mon Sep 17 00:00:00 2001 From: Benjamin Bach Date: Wed, 7 Aug 2024 14:33:15 +0200 Subject: [PATCH] WIP: Add generic email functionality, extending for sending out invite emails and order links... --- pyproject.toml | 2 + src/membership/emails.py | 88 +++++++++++++++++++ .../templates/membership/emails/base.txt | 9 ++ .../membership/emails/invitation.txt | 11 +++ src/membership/views.py | 25 ++++++ 5 files changed, 135 insertions(+) create mode 100644 src/membership/emails.py create mode 100644 src/membership/templates/membership/emails/base.txt create mode 100644 src/membership/templates/membership/emails/invitation.txt diff --git a/pyproject.toml b/pyproject.toml index cd9b9bb..7ebb19b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -147,6 +147,8 @@ ignore = [ "TD", # TODO, FIXME, XXX "ANN002", # Missing type annotation for `*args` "ANN003", # Missing type annotation for `**kwargs` + "FBT001", # Misbehaves: Complains about positional arguments that are keyworded. + "FBT002", # Misbehaves: Complains about positional arguments that are keyworded. ] [tool.ruff.lint.isort] diff --git a/src/membership/emails.py b/src/membership/emails.py new file mode 100644 index 0000000..20a243a --- /dev/null +++ b/src/membership/emails.py @@ -0,0 +1,88 @@ +"""Send email to members, using templates and contexts for the emails. + +* We keep everything as plain text for now. +* Notice that emails can be multilingual +* Generally, an email consists of templates (for body and subject) and a get_context() method. +""" + +from django.contrib import messages +from django.contrib.sites.shortcuts import get_current_site +from django.core.mail.message import EmailMessage +from django.http import HttpRequest +from django.template import loader +from django.utils import translation +from django.utils.translation import gettext_lazy as _ + + +class BaseEmail(EmailMessage): + """Send emails via templated body and subjects. + + This base class is extended for all email functionality. + """ + + template = "membership/email/base.txt" + # Optional: Set to a template path for subject + template_subject = None + default_subject = "SET SUBJECT HERE" + + def __init__(self, request: HttpRequest, *args, **kwargs) -> None: # noqa: D107 + self.context = kwargs.pop("context", {}) + self.user = kwargs.pop("user", None) + if self.user: + kwargs["to"] = [self.user.email] + self.context["user"] = self.user + self.context["recipient_name"] = self.user.get_display_name() + + # Necessary to set request before instantiating body and subject + self.request = request + kwargs.setdefault("subject", self.get_subject()) + kwargs.setdefault("body", self.get_body()) + + super().__init__(*args, **kwargs) + + def get_context_data(self) -> dict: + """Resolve common context for sending emails. + + When overwriting, remember to call this via super(). + """ + c = self.context + site = get_current_site(self.request) + c["request"] = self.request + c["domain"] = site.domain + c["site_name"] = site.name + c["protocol"] = "http" if self.request and not self.request.is_secure() else "https" + return c + + def get_body(self) -> str: + """Build the email body from template and context.""" + if self.user and self.user.language_code: + with translation.override(self.user.language_code): + body = loader.render_to_string(self.template, self.get_context_data()) + else: + body = loader.render_to_string(self.template, self.get_context_data()) + return body + + def get_subject(self) -> str: + """Build the email subject from template or self.default_subject.""" + if self.user and self.user.language_code: + with translation.override(self.user.language_code): + if self.template_subject: + subject = loader.render_to_string(self.template_subject, self.get_context_data()).strip() + else: + subject = str(self.default_subject) + elif self.template_subject: + subject = loader.render_to_string(self.template_subject, self.get_context_data()).strip() + else: + subject = str(self.default_subject) + return subject + + def send_with_feedback(self, success_msg: str | None = None, no_message: bool = False) -> None: + """Send email, possibly adding feedback via django.contrib.messages.""" + if not success_msg: + success_msg = _("Email successfully sent to {}").format(", ".join(self.to)) + try: + self.send(fail_silently=False) + if not no_message: + messages.success(self.request, success_msg) + except RuntimeError: + messages.error(self.request, _("Not sent, something wrong with the mail server.")) diff --git a/src/membership/templates/membership/emails/base.txt b/src/membership/templates/membership/emails/base.txt new file mode 100644 index 0000000..9a86384 --- /dev/null +++ b/src/membership/templates/membership/emails/base.txt @@ -0,0 +1,9 @@ +{% load i18n %}{% block greeting %}{% blocktrans %}Dear {{ recipient_name }},{% endblocktrans %}{% endblock %} + +{% block content %}{% endblock %} + + +Regards, +{{ site_name }} + +{{ protocol }}://{{ domain }} diff --git a/src/membership/templates/membership/emails/invitation.txt b/src/membership/templates/membership/emails/invitation.txt new file mode 100644 index 0000000..3e83848 --- /dev/null +++ b/src/membership/templates/membership/emails/invitation.txt @@ -0,0 +1,11 @@ +{% extends "users/mail/base.txt" %}{% load i18n %} + +{% block content %}{% url 'users:login_token' token=user.token_uuid as login_url %}{% blocktrans with expiry=user.token_expiry next=next %}Here is a 1-time code for confirming your account: + +{{ token_passphrase }} + +Use this code within 1 hour (before {{ expiry }}). You can login here: + +{{ protocol }}://{{ domain }}{{ login_url }}?next={{ next }} + +If you did not request this account, get in touch with us.{% endblocktrans %}{% endblock %} diff --git a/src/membership/views.py b/src/membership/views.py index eeb2e58..0c5ebf9 100644 --- a/src/membership/views.py +++ b/src/membership/views.py @@ -5,6 +5,7 @@ from __future__ import annotations from typing import TYPE_CHECKING from django.utils.translation import gettext_lazy as _ +from django_ratelimit.decorators import ratelimit from django_view_decorator import namespaced_decorator_factory from utils.view_utils import RenderConfig from utils.view_utils import RowAction @@ -113,3 +114,27 @@ def members_admin_detail(request: HttpRequest, member_id: int) -> HttpResponse: template_name="membership/members_admin_detail.html", context=context, ) + + +@ratelimit(group="membership", key="ip", rate="10/d", method="ALL", block=True) +@member_view( + paths="invite//", + name="membership-invite", + login_required=False, +) +def invite(request: HttpRequest, token: str) -> HttpResponse: + """View to invite a member to create a membership. + + The token belongs to a non-active Member object. If the token is valid, + the caller is allowed to create a membership. + + We ratelimit this view so it's not possible to brute-force tokens. + """ + context = { + "token": token, + } + return render( + request=request, + template_name="membership/invite.html", + context=context, + )