diff --git a/README.md b/README.md index d200a2c..26dc094 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,20 @@ This will start the webserver, the worker for the background tasks, and the watc Whenever you make changes to the Python code, the server will automatically reload. The watcher for Tailwind will also automatically rebuild the CSS. +## Email + +To test emails and how they look, you can also use mailhog. +Run mailhog in a docker and approach it on localhost:8025 +```bash +docker run -p 8025:8025 -p 1025:1025 mailhog/mailhog +``` +And add these config in settings.py +```python +EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend" +EMAIL_URL = "127.0.0.1" +EMAIL_PORT = 1025 +``` + ## Localization All strings are currently in English by default, and have localizations to Dutch. diff --git a/symfexit/emails/__init__.py b/symfexit/emails/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/symfexit/emails/_templates/__init__.py b/symfexit/emails/_templates/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/symfexit/emails/_templates/base.py b/symfexit/emails/_templates/base.py new file mode 100644 index 0000000..ae314bd --- /dev/null +++ b/symfexit/emails/_templates/base.py @@ -0,0 +1,133 @@ +import re +import urllib.parse +from dataclasses import dataclass +from typing import ClassVar, TypedDict, TypeVar + +from constance import config +from django.utils.translation import gettext_lazy as _ + +from symfexit.root import settings + + +class BaseContext(TypedDict): + site_title: str + full_date_time: str + + +T = TypeVar("T", bound=BaseContext) + +BASE_CONTEXT_KEY_LENGTH = 4 +INPUT_CONTEXT_KEY_LENGTH = 3 + + +class BaseEmailComponent[T]: + label: ClassVar[str] + code: ClassVar[str] + description: ClassVar[str] + context: dict + + def __init__(self, context: T): + self.context = {**self.get_context_values(), **context} + + @classmethod + def get_base_context(cls): + """Content that is global and for every email the same.""" + return [ + ("site_title", _("Main title of this site"), config.SITE_TITLE), + ("site_url", _("Main site of the organisation"), config.MAIN_SITE), + ( + "site_logo", + _("Organisation logo"), + f"{config.MAIN_SITE}/{settings.MEDIA_URL}{config.LOGO_IMAGE}", + ), + ] + + @classmethod + def get_context_values(self): + return {c[0]: c[2] for c in self.get_base_context()} + + @classmethod + def get_context_options(self): + return { + **{ + c[0]: (c[1], c[3] if len(c) > BASE_CONTEXT_KEY_LENGTH - 1 else False) + for c in self.get_base_context() + }, + **{ + c[0]: (c[1], c[2] if len(c) > INPUT_CONTEXT_KEY_LENGTH - 1 else False) + for c in self.get_input_context() + }, + } + + @classmethod + def get_input_context(cls): + """(code, label, required=False)""" + return [] + + @dataclass + class TemplateValidation: + formatted_template: str + unknown_context_keys: list[str] + missing_context_keys: list[str] + + @classmethod + def validate_template(cls, template_text) -> TemplateValidation: + # Check if used keys are correctly spelled, if not return list of all possible and also return wrongly spelled/missing keys + all_keys = [] + required_keys = [] + unknown_keys = [] + + for key_data in cls.get_base_context(): + all_keys.append(key_data[0]) + if len(key_data) == BASE_CONTEXT_KEY_LENGTH: + required_keys.append(key_data[0]) + + for key_data in cls.get_input_context(): + all_keys.append(key_data[0]) + if len(key_data) == INPUT_CONTEXT_KEY_LENGTH: + if key_data[2]: + required_keys.append(key_data[0]) + + template_text = urllib.parse.unquote(template_text) + + # Replace function + def replace_keys(match): + key = match.group(1).strip() # Get the captured group and trim whitespace + return f"{{{{ {key} }}}}" # Format it to {{ key }} + + # Perform the replacement + matches = re.findall(r"{{\s*(.*?)\s*}}", template_text) + formatted_template = re.sub(r"{{\s*(.*?)\s*}}", replace_keys, template_text) + + for m in matches: + if m in required_keys: + required_keys.remove(m) + if m not in all_keys: + unknown_keys.append(m) + + return BaseEmailComponent.TemplateValidation( + formatted_template, unknown_keys, required_keys + ) + + +class LayoutContext(TypedDict): + content: str + + +LayoutContextType = TypeVar("LayoutContextType", bound=LayoutContext) + + +class WrapperLayout[LayoutContext](BaseEmailComponent[LayoutContext]): + @classmethod + def get_input_context(cls): + return [ + *super().get_input_context(), + ("content", _("Email template outlet"), True), + ] + + +class BodyTemplate[T](BaseEmailComponent[T]): + subject_template: ClassVar[str] + html_template: ClassVar[str] + text_template: ClassVar[str] + pass diff --git a/symfexit/emails/_templates/emails/__init__.py b/symfexit/emails/_templates/emails/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/symfexit/emails/_templates/emails/apply.py b/symfexit/emails/_templates/emails/apply.py new file mode 100644 index 0000000..3bb3fd0 --- /dev/null +++ b/symfexit/emails/_templates/emails/apply.py @@ -0,0 +1,27 @@ +from typing import TypedDict + +from django.utils.translation import gettext_lazy as _ + +from symfexit.emails._templates.base import BodyTemplate + + +class ApplyContext(TypedDict): + firstname: str + + +class ApplyEmail(BodyTemplate[ApplyContext]): + code = "apply" + label = _("Apply template") + + subject_template = "Applied" + html_template = "You succesfully applied" + text_template = "You succesfully applied" + + @classmethod + def get_input_context(cls): + return [ + *super().get_input_context(), + {"firstname": _("First name of the member")}, + ] + + pass diff --git a/symfexit/emails/_templates/emails/password_request.py b/symfexit/emails/_templates/emails/password_request.py new file mode 100644 index 0000000..aef82cc --- /dev/null +++ b/symfexit/emails/_templates/emails/password_request.py @@ -0,0 +1,49 @@ +from typing import TypedDict + +from django.utils.translation import gettext_lazy as _ + +from symfexit.emails._templates.base import BodyTemplate + + +class ApplyContext(TypedDict): + firstname: str + email: str + url: str + + +class PasswordResetEmail(BodyTemplate[ApplyContext]): + code = "password-reset" + label = _("Request new password template") + + subject_template = "Password reset on {{site_url}}" + html_template = """

You're receiving this email because you requested a password reset for your user account at {{ site_url }}.

+ +

Please go to the following page and choose a new password:

+ +

{{url}}

+ +

In case you’ve forgotten, you are: {{email}}

+ +

Thanks for using our site!

+ +{{site_title}}""" + text_template = """You're receiving this email because you requested a password reset for your user account at {{ site_url }}. + +Please go to the following page and choose a new password: + +{{url}} + +In case you’ve forgotten, you are: {{email}} + +Thanks for using our site! + +{{site_title}}""" + + @classmethod + def get_input_context(cls): + return [ + *super().get_input_context(), + ("firstname", _("First name of the member")), + ("email", _("Email address of the member")), + ("url", _("Url of the reset password link"), True), + ] diff --git a/symfexit/emails/_templates/layouts/__init__.py b/symfexit/emails/_templates/layouts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/symfexit/emails/_templates/layouts/base.py b/symfexit/emails/_templates/layouts/base.py new file mode 100644 index 0000000..e6279bd --- /dev/null +++ b/symfexit/emails/_templates/layouts/base.py @@ -0,0 +1,8 @@ +from django.utils.translation import gettext_lazy as _ + +from symfexit.emails._templates.base import WrapperLayout + + +class BaseLayout(WrapperLayout): + label = _("layout") + code = "Layout template" diff --git a/symfexit/emails/_templates/manager.py b/symfexit/emails/_templates/manager.py new file mode 100644 index 0000000..5ed3f08 --- /dev/null +++ b/symfexit/emails/_templates/manager.py @@ -0,0 +1,34 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +from symfexit.emails._templates.base import BaseEmailComponent, WrapperLayout +from symfexit.emails._templates.emails.apply import ApplyEmail +from symfexit.emails._templates.emails.password_request import PasswordResetEmail +from symfexit.emails._templates.layouts.base import BaseLayout + +if TYPE_CHECKING: + from .base import BodyTemplate + + +class BaseManager: + _registry: list[type[BaseEmailComponent]] + + @classmethod + def get_as_choices(cls) -> list[tuple[str, str]]: + return [(e.code, e.label) for e in cls._registry] + + @classmethod + def find(cls, code: str) -> type[BodyTemplate] | None: + for e in cls._registry: + if e.code == code: + return e + return None + + +class EmailTemplateManager(BaseManager): + _registry: list[type[BodyTemplate]] = [ApplyEmail, PasswordResetEmail] + + +class EmailLayoutManager(BaseManager): + _registry: list[type[WrapperLayout]] = [BaseLayout] diff --git a/symfexit/emails/_templates/render.py b/symfexit/emails/_templates/render.py new file mode 100644 index 0000000..b2bfdeb --- /dev/null +++ b/symfexit/emails/_templates/render.py @@ -0,0 +1,98 @@ +import urllib.parse + +from django.core.mail import send_mail +from django.template import Context, Template + +from symfexit.emails._templates.base import BodyTemplate +from symfexit.emails._templates.manager import EmailLayoutManager +from symfexit.emails.models import EmailTemplate + +DEFAULT_HTML_BODY = """ + + + + + + {{ content }} + +""" + +DEFAULT_TEXT_BODY = """{{content}}""" + + +# allow overriding of all email variables +def send_email( # noqa: PLR0913 + email_template: BodyTemplate, + recipient_list: list[str] | str, + lang: str | None = None, + subject: str | None = None, + message: str | None = None, + from_email: str | None = None, + fail_silently: bool = False, + html_message: str | None = None, +): + if isinstance(recipient_list, str): + recipient_list = [recipient_list] + + db_email_template = EmailTemplate.objects.filter(template=email_template.code).first() + + rendered_subject, rendered_body, rendered_text_body = render_email( + email_template, db_email_template + ) + + mail = { + "from_email": from_email or db_email_template.from_email if db_email_template else None, + "recipient_list": recipient_list, + "subject": subject or rendered_subject, + "message": message or rendered_text_body, + "html_message": html_message or rendered_body, + "fail_silently": fail_silently, + } + return send_mail(**mail) + + +def render_email( + email_template: BodyTemplate, db_email_template: EmailTemplate +) -> tuple[str, str, str]: + context = email_template.context + title = render( + db_email_template.subject if db_email_template else email_template.subject_template, context + ) + + html_content = render( + db_email_template.body if db_email_template else email_template.html_template, context + ) + text_content = render( + db_email_template.text_body if db_email_template else email_template.text_template, + context, + ) + + # wrap content with layout, if set + if db_email_template: + if email_layout := db_email_template.layout: + layout = EmailLayoutManager.find(email_layout.template) + context = {**context, **layout.get_context_values()} + html_content = render(email_layout.body, {**context, "content": html_content}) + text_content = render(email_layout.text_body, {**context, "content": text_content}) + + # render the base body, mostly make html file from content + html = render(DEFAULT_HTML_BODY, {**context, "content": html_content}) + text = render(DEFAULT_TEXT_BODY, {**context, "content": text_content}) + + return title, html, text + + +def render(template_string: str, context: dict): + template = Template(urllib.parse.unquote(template_string)) + return template.render(Context(context)) diff --git a/symfexit/emails/admin.py b/symfexit/emails/admin.py new file mode 100644 index 0000000..2f24842 --- /dev/null +++ b/symfexit/emails/admin.py @@ -0,0 +1,174 @@ +# Check if used keys are correctly spelled, if not return list of all possible and also return wrongly spelled/missing keys +# symfexit/emails/admin.py +from typing import Any + +from django import forms +from django.contrib import admin +from django.forms.utils import ErrorList +from django.utils.translation import gettext_lazy as _ + +from symfexit.emails._templates.manager import EmailLayoutManager, EmailTemplateManager +from symfexit.emails.models import EmailLayout, EmailTemplate + + +# ---------------------------------------------------------------------- +# Helper: Validate template context keys +# ---------------------------------------------------------------------- +def check_ctx_keys_correct( + template, + ctx_dict: dict[str, tuple[str, bool]], + data: dict, + _errors: Any, + field_key: str, + value: str, + missing: bool = True, +) -> dict[str, ErrorList]: + """ + Validate a template string against its expected context keys, update the + cleaned ``data`` dictionary, and collect any errors. + + Parameters + ---------- + template + Object providing a :py:meth:`validate_template` method that returns an + object with ``formatted_template``, ``missing_context_keys`` and + ``unknown_context_keys`` attributes. + ctx_dict + Mapping of context key names to a tuple ``(description, is_required)``. + data + Mutable mapping where the formatted template will be stored under + ``field_key``. + _errors + Mutable mapping that holds :class:`django.forms.utils.ErrorList` for + each field; it is mutated in place. + field_key + The form field name being validated. + value + The raw string entered by the user. + missing + If ``True`` (default), report missing *required* keys; if ``False`` + only unknown keys are reported. + + Returns + ------- + MutableMapping[str, ErrorList] + The (mutated) ``_errors`` mapping. + """ + # Run the template validator and store the formatted string + validation_result = template.validate_template(value) + data[field_key] = validation_result.formatted_template + + missing_keys = validation_result.missing_context_keys + unknown_keys = validation_result.unknown_context_keys + + # Report errors if there are unknown keys or (when ``missing`` is True) + # any required keys are missing. + if unknown_keys or (missing and missing_keys): + error_messages = [] + + # Missing required keys (grouped) + if missing and missing_keys: + # Show the missing keys in a comma‑separated list + missing_list = ", ".join(f"{{{{{mk}}}}}" for mk in missing_keys) + error_messages.append(_("The following required keys are missing: %s") % missing_list) + + # Unknown keys (grouped) + if unknown_keys: + unknown_list = ", ".join(f"{{{{{uk}}}}}" for uk in unknown_keys) + error_messages.append(_("The following keys are not recognised: %s") % unknown_list) + + # Show all allowed keys (with a '*' for required ones) + error_messages.append(_("Allowed keys:")) + for key, (description, required) in ctx_dict.items(): + required_flag = "*" if required else "" + error_messages.append(f" - {{{{{key}}}}}: {description} {required_flag}".strip()) + + # Attach the nicely formatted errors to the field + _errors[field_key] = ErrorList(error_messages) + + return _errors + + +class EmailLayoutForm(forms.ModelForm): + class Meta: + model = EmailLayout + exclude = [] # noqa: DJ006 + + def save(self, commit=...): + return super().save(commit) + # this function will be used for the validation + + def clean(self): + # data from the form is fetched using super function + super().clean() + + body_key = "body" + text_body_key = "text_body" + + template_identifier = self.cleaned_data.get("template", "") + body = self.cleaned_data.get(body_key, "") + text_body = self.cleaned_data.get(text_body_key, "") + + # get template by identifier: template_identifier + template = EmailLayoutManager.find(template_identifier) + ctx_dict = template.get_context_options() + + self.data = self.data.copy() + + check_ctx_keys_correct(template, ctx_dict, self.data, self._errors, body_key, body) + check_ctx_keys_correct( + template, ctx_dict, self.data, self._errors, text_body_key, text_body + ) + + return self.cleaned_data + + +class EmailTemplateForm(forms.ModelForm): + class Meta: + model = EmailTemplate + exclude = [] # noqa: DJ006 + + def save(self, commit=...): + return super().save(commit) + # this function will be used for the validation + + def clean(self): + # data from the form is fetched using super function + super().clean() + + subject_key = "subject" + body_key = "body" + text_body_key = "text_body" + + template_identifier = self.cleaned_data.get("template", "") + subject = self.cleaned_data.get(subject_key, "") + body = self.cleaned_data.get(body_key, "") + text_body = self.cleaned_data.get(text_body_key, "") + + # get template by identifier: template_identifier + template = EmailTemplateManager.find(template_identifier) + ctx_dict = template.get_context_options() + + self.data = self.data.copy() + + check_ctx_keys_correct( + template, ctx_dict, self.data, self._errors, subject_key, subject, False + ) + check_ctx_keys_correct(template, ctx_dict, self.data, self._errors, body_key, body) + check_ctx_keys_correct( + template, ctx_dict, self.data, self._errors, text_body_key, text_body + ) + + return self.cleaned_data + + +@admin.register(EmailLayout) +class EmailLayoutAdmin(admin.ModelAdmin): + form = EmailLayoutForm + pass + + +@admin.register(EmailTemplate) +class EmailTemplateAdmin(admin.ModelAdmin): + form = EmailTemplateForm + pass diff --git a/symfexit/emails/apps.py b/symfexit/emails/apps.py new file mode 100644 index 0000000..3d0cc62 --- /dev/null +++ b/symfexit/emails/apps.py @@ -0,0 +1,8 @@ +from django.apps import AppConfig +from django.utils.translation import gettext_lazy as _ + + +class EmailTemplateConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "symfexit.emails" + verbose_name = _("Email templates") diff --git a/symfexit/emails/locale/nl/LC_MESSAGES/django.mo b/symfexit/emails/locale/nl/LC_MESSAGES/django.mo new file mode 100644 index 0000000..b17794a Binary files /dev/null and b/symfexit/emails/locale/nl/LC_MESSAGES/django.mo differ diff --git a/symfexit/emails/locale/nl/LC_MESSAGES/django.po b/symfexit/emails/locale/nl/LC_MESSAGES/django.po new file mode 100644 index 0000000..9a6066b --- /dev/null +++ b/symfexit/emails/locale/nl/LC_MESSAGES/django.po @@ -0,0 +1,113 @@ +# SOME DESCRIPTIVE TITLE. +# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER +# This file is distributed under the same license as the PACKAGE package. +# FIRST AUTHOR , YEAR. +# +#, fuzzy +msgid "" +msgstr "" +"Project-Id-Version: PACKAGE VERSION\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2025-11-15 15:35+0100\n" +"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" +"Last-Translator: FULL NAME \n" +"Language-Team: LANGUAGE \n" +"Language: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: 8bit\n" +"Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#: symfexit/emails/_templates/base.py:36 +msgid "Main title of this site" +msgstr "Hoofdtitel van deze site" + +#: symfexit/emails/_templates/base.py:37 +msgid "Main site of the organisation" +msgstr "Hoofdsite van de organisatie" + +#: symfexit/emails/_templates/base.py:40 +msgid "Organisation logo" +msgstr "Logo van de organisatie" + +#: symfexit/emails/_templates/base.py:125 +#, fuzzy +#| msgid "Email templates" +msgid "Email template outlet" +msgstr "E-mail sjablonen" + +#: symfexit/emails/_templates/emails/apply.py:14 +msgid "Apply template" +msgstr "Aanmelden sjabloon" + +#: symfexit/emails/_templates/emails/apply.py:24 +#: symfexit/emails/_templates/emails/password_request.py:46 +msgid "First name of the member" +msgstr "Voornaam van het lid" + +#: symfexit/emails/_templates/emails/password_request.py:16 +msgid "Request new password template" +msgstr "Nieuw wachtwoord aanvragen sjabloon" + +#: symfexit/emails/_templates/emails/password_request.py:47 +#, fuzzy +#| msgid "Full name of the member" +msgid "Email address of the member" +msgstr "Volledige naam van het lid" + +#: symfexit/emails/_templates/emails/password_request.py:48 +msgid "Url of the reset password link" +msgstr "URL van de link voor het opnieuw instellen van het wachtwoord" + +#: symfexit/emails/_templates/layouts/base.py:7 +#, fuzzy +#| msgid "email layout" +msgid "layout" +msgstr "e-mail lay-out" + +#: symfexit/emails/admin.py:73 +#, python-format +msgid "The following required keys are missing: %s" +msgstr "" + +#: symfexit/emails/admin.py:78 +#, python-format +msgid "The following keys are not recognised: %s" +msgstr "" + +#: symfexit/emails/admin.py:81 +msgid "Allowed keys:" +msgstr "Toegestane sleutels" + +#: symfexit/emails/apps.py:8 +msgid "Email templates" +msgstr "E-mail sjablonen" + +#: symfexit/emails/models.py:11 symfexit/emails/models.py:42 +msgid "template" +msgstr "sjabloon" + +#: symfexit/emails/models.py:20 symfexit/emails/models.py:53 +msgid "body" +msgstr "inhoud" + +#: symfexit/emails/models.py:23 symfexit/emails/models.py:56 +msgid "text body" +msgstr "tekstinhoud" + +#: symfexit/emails/models.py:24 symfexit/emails/models.py:57 +msgid "Text body is used for people who don't allow html in their emails." +msgstr "" +"Tekstinhoud wordt gebruikt voor mensen die geen HTML in hun e-mails toestaan." + +#: symfexit/emails/models.py:39 +msgid "email layout" +msgstr "e-mail lay-out" + +#: symfexit/emails/models.py:50 +msgid "from email" +msgstr "afzender" + +#: symfexit/emails/models.py:51 +msgid "subject" +msgstr "onderwerp" diff --git a/symfexit/emails/migrations/0001_initial.py b/symfexit/emails/migrations/0001_initial.py new file mode 100644 index 0000000..7542f71 --- /dev/null +++ b/symfexit/emails/migrations/0001_initial.py @@ -0,0 +1,37 @@ +# Generated by Django 5.2.7 on 2025-11-14 14:18 + +import django.db.models.deletion +import tinymce.models +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ] + + operations = [ + migrations.CreateModel( + name='EmailLayout', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('template', models.CharField(choices=[('apply', 'Apply template'), ('password-reset', 'Request new password template')], max_length=80, unique=True, verbose_name='template')), + ('body', tinymce.models.HTMLField(verbose_name='body')), + ('text_body', models.TextField(help_text="Text body is used for people who don't allow html in their emails.", verbose_name='text body')), + ], + ), + migrations.CreateModel( + name='EmailTemplate', + fields=[ + ('id', models.AutoField(primary_key=True, serialize=False)), + ('template', models.CharField(choices=[('apply', 'Apply template'), ('password-reset', 'Request new password template')], max_length=80, unique=True, verbose_name='template')), + ('from_email', models.EmailField(max_length=254, verbose_name='from email')), + ('subject', models.TextField(verbose_name='subject')), + ('body', tinymce.models.HTMLField(verbose_name='body')), + ('text_body', models.TextField(help_text="Text body is used for people who don't allow html in their emails.", verbose_name='text body')), + ('layout', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='children', to='emails.emaillayout', verbose_name='email layout')), + ], + ), + ] diff --git a/symfexit/emails/migrations/0002_alter_emaillayout_template_and_more.py b/symfexit/emails/migrations/0002_alter_emaillayout_template_and_more.py new file mode 100644 index 0000000..30ce915 --- /dev/null +++ b/symfexit/emails/migrations/0002_alter_emaillayout_template_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.7 on 2025-11-15 14:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('emails', '0001_initial'), + ] + + operations = [ + migrations.AlterField( + model_name='emaillayout', + name='template', + field=models.CharField(choices=[], max_length=80, verbose_name='template'), + ), + migrations.AlterField( + model_name='emailtemplate', + name='template', + field=models.CharField(choices=[], max_length=80, unique=True, verbose_name='template'), + ), + ] diff --git a/symfexit/emails/migrations/0003_alter_emaillayout_template_and_more.py b/symfexit/emails/migrations/0003_alter_emaillayout_template_and_more.py new file mode 100644 index 0000000..9b50e63 --- /dev/null +++ b/symfexit/emails/migrations/0003_alter_emaillayout_template_and_more.py @@ -0,0 +1,23 @@ +# Generated by Django 5.2.7 on 2025-11-15 14:46 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('emails', '0002_alter_emaillayout_template_and_more'), + ] + + operations = [ + migrations.AlterField( + model_name='emaillayout', + name='template', + field=models.CharField(choices=[('Layout template', 'layout')], max_length=80, verbose_name='template'), + ), + migrations.AlterField( + model_name='emailtemplate', + name='template', + field=models.CharField(choices=[('apply', 'Apply template'), ('password-reset', 'Request new password template')], max_length=80, unique=True, verbose_name='template'), + ), + ] diff --git a/symfexit/emails/migrations/__init__.py b/symfexit/emails/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/symfexit/emails/models.py b/symfexit/emails/models.py new file mode 100644 index 0000000..f7a1a66 --- /dev/null +++ b/symfexit/emails/models.py @@ -0,0 +1,62 @@ +from django.db import models +from django.utils.translation import gettext_lazy as _ +from tinymce.models import HTMLField + +from symfexit.emails._templates.manager import EmailLayoutManager, EmailTemplateManager + + +class EmailLayout(models.Model): + id = models.AutoField(primary_key=True) + + template = models.CharField( + _("template"), + unique=False, + blank=False, + max_length=80, + choices=EmailLayoutManager.get_as_choices(), + ) + # we could add language as well and make template + language a unique key + + body = HTMLField( + _("body"), + ) + text_body = models.TextField( + _("text body"), + help_text=_("Text body is used for people who don't allow html in their emails."), + ) + + def __str__(self) -> str: + return f"{self.template}" + + +class EmailTemplate(models.Model): + id = models.AutoField(primary_key=True) + layout = models.ForeignKey( + "EmailLayout", + on_delete=models.SET_NULL, + related_name="children", + null=True, + blank=True, + verbose_name=_("email layout"), + ) + template = models.CharField( + _("template"), + unique=True, + blank=False, + max_length=80, + choices=EmailTemplateManager.get_as_choices(), + ) + # we could add language as well and make template + language a unique key + + from_email = models.EmailField(_("from email")) + subject = models.TextField(_("subject")) + body = HTMLField( + _("body"), + ) + text_body = models.TextField( + _("text body"), + help_text=_("Text body is used for people who don't allow html in their emails."), + ) + + def __str__(self) -> str: + return f"{self.template}: {self.subject}" diff --git a/symfexit/emails/tests.py b/symfexit/emails/tests.py new file mode 100644 index 0000000..4e20548 --- /dev/null +++ b/symfexit/emails/tests.py @@ -0,0 +1,185 @@ +from typing import TypedDict + +from constance.test.unittest import override_config +from django.core import mail +from django.template import Context, Template +from django.test import TestCase, override_settings + +# from unittest.mock import patch # removed, not used anymore +# Import the components to be tested +from symfexit.emails._templates.base import ( + BaseEmailComponent, + BodyTemplate, + WrapperLayout, +) +from symfexit.emails._templates.manager import EmailTemplateManager +from symfexit.emails._templates.render import send_email + +BASE_CONTEXT = { + "site_title": "Test Site", + "site_url": "https://example.com", + "site_logo": "https://example.com/static/logo.png", +} + + +class EmailComponentTests(TestCase): + """ + Django-style unit tests for the email component system. + """ + + def setUp(self): + # Base context that all tests will use + self.base_context = BASE_CONTEXT + + # Custom context that extends the base one + self.custom_context = self.base_context.copy() + self.custom_context["custom_key"] = "Custom value" + + # --------------------------------------------------------------------- + # Tests for BaseEmailComponent + # --------------------------------------------------------------------- + @override_config( + SITE_TITLE=BASE_CONTEXT["site_title"], + MAIN_SITE=BASE_CONTEXT["site_url"], + LOGO_IMAGE="logo.png", + ) + def test_get_context_values(self): + """BaseEmailComponent.get_context_values() merges base context correctly.""" + ctx = BaseEmailComponent.get_context_values() + + self.assertEqual(ctx["site_title"], self.base_context["site_title"]) + self.assertEqual(ctx["site_url"], self.base_context["site_url"]) + self.assertEqual( + ctx["site_logo"], + f"{self.base_context['site_url']}/media/logo.png", + ) + + @override_config( + SITE_TITLE=BASE_CONTEXT["site_title"], + MAIN_SITE=BASE_CONTEXT["site_url"], + LOGO_IMAGE="logo.png", + ) + def test_get_context_options(self): + """The options dictionary contains the base keys and the input keys.""" + opts = BaseEmailComponent.get_context_options() + + # Base keys should be present + self.assertIn("site_title", opts) + self.assertIn("site_url", opts) + self.assertIn("site_logo", opts) + + # No input keys by default + self.assertNotIn("content", opts) + + @override_config( + SITE_TITLE=BASE_CONTEXT["site_title"], + MAIN_SITE=BASE_CONTEXT["site_url"], + LOGO_IMAGE="logo.png", + ) + def test_wrapper_layout_get_input_context(self): + """WrapperLayout adds the 'content' key to the input context.""" + opts = WrapperLayout.get_context_options() + + self.assertIn("content", opts) + # The content key is required + self.assertTrue(opts["content"][1] is True) + + @override_config( + SITE_TITLE="Site", + MAIN_SITE="https://site.com", + LOGO_IMAGE="logo.png", + ) + def test_validate_template_known_and_unknown_keys(self): + """BaseEmailComponent.validate_template correctly identifies unknown and missing keys.""" + template = ( + "Hello {{ site_title }}, check out {{ site_url }}, " + "and this is a {{ missing_key }} and an {{ unknown }}." + ) + result = WrapperLayout.validate_template(template) + + expected_formatted = ( + "Hello {{ site_title }}, check out {{ site_url }}, " + "and this is a {{ missing_key }} and an {{ unknown }}." + ) + self.assertEqual(result.formatted_template, expected_formatted) + + # Missing keys (site_logo is required but not present) + self.assertEqual(set(result.missing_context_keys), {"content"}) + + # Unknown keys + self.assertEqual(set(result.unknown_context_keys), {"missing_key", "unknown"}) + + # --------------------------------------------------------------------- + # Tests for rendering & sending emails + # --------------------------------------------------------------------- + def test_render_email_template(self): + """Rendering a Django template with the base context works.""" + template_text = ( + "Title: {{ site_title }}\n" + "URL: {{ site_url }}\n" + "Logo: {{ site_logo }}\n" + "Custom: {{ custom_key }}\n" + ) + django_template = Template(template_text) + + # Merge the base context from the component with the custom one + context = {**BaseEmailComponent.get_context_values(), **self.custom_context} + rendered = django_template.render(Context(context)) + + self.assertIn("Title: Test Site", rendered) + self.assertIn("URL: https://example.com", rendered) + self.assertIn("Logo: https://example.com/static/logo.png", rendered) + self.assertIn("Custom: Custom value", rendered) + + @override_settings( + EMAIL_BACKEND="django.core.mail.backends.locmem.EmailBackend", + DEFAULT_FROM_EMAIL="no-reply@example.com", + ) + def test_send_email_via_backend(self): + """ + If a send_email helper exists, it should use Django's email backend. + The test is written defensively – it will simply skip if the helper + is not present in this repository. + """ + + class ApplyContext(TypedDict): + custom_key: str + + class TestEmail(BodyTemplate[ApplyContext]): + code = "test" + label = "Test template" + + @classmethod + def get_input_context(cls): + return [ + *super().get_input_context(), + {"custom_key": "First name of the member"}, + ] + + pass + + subject_template = "Welcome to {{ site_title }}" + html_template = "Hello {{ custom_key }}, thanks for joining {{ site_title }}." + text_template = "Hello {{ custom_key }}, thanks for joining {{ site_title }}." + + # Call the helper – the helper is expected to send an email and return + # the email instance that Django's mail backend stores. + send_email( + TestEmail({"custom_key": "Custom value"}), + recipient_list=["user@example.com"], + from_email="noreply@example.com", + ) + + # Verify that the email is queued by the locmem backend + self.assertEqual(len(mail.outbox), 1) + sent_email = mail.outbox[0] + self.assertEqual(sent_email.subject, "Welcome to Symfexit") + self.assertEqual(sent_email.to, ["user@example.com"]) + self.assertNotIn("User@example.com", sent_email.body) # sanity check + self.assertIn("Custom value", sent_email.body) + + def test_every_mail_has_default_template(self): + for t in EmailTemplateManager._registry: + assert t.subject_template + assert t.html_template + assert t.text_template diff --git a/symfexit/emails/urls.py b/symfexit/emails/urls.py new file mode 100644 index 0000000..e69de29 diff --git a/symfexit/emails/views.py b/symfexit/emails/views.py new file mode 100644 index 0000000..e69de29 diff --git a/symfexit/root/settings.py b/symfexit/root/settings.py index 052ae11..df4e05c 100644 --- a/symfexit/root/settings.py +++ b/symfexit/root/settings.py @@ -177,6 +177,7 @@ def setting_from_env( ] + enable_if(django_browser_reload_enabled, ["django_browser_reload"]) TENANT_APPS = [ + "tinymce", "django.contrib.contenttypes", "django.contrib.auth", "django.contrib.sessions", @@ -190,6 +191,7 @@ def setting_from_env( # django.contrib.admin, which contains translations which should be # overwritten by our own translations in AdminSiteConfig "symfexit.adminsite.apps.AdminSiteConfig", + "symfexit.emails.apps.EmailTemplateConfig", "symfexit.menu.apps.MenuConfig", "symfexit.members.apps.MembersConfig", "symfexit.payments.apps.PaymentsConfig", @@ -384,3 +386,17 @@ def setting_from_env( # https://docs.djangoproject.com/en/5.0/ref/settings/#std-setting-AUTH_USER_MODEL AUTH_USER_MODEL = "members.User" + +TINYMCE_DEFAULT_CONFIG = { + "relative_urls": False, + "remove_script_host": False, + "convert_urls": False, + "menubar": "edit view insert format tools table help", + "plugins": "advlist autolink lists link charmap print preview anchor searchreplace visualblocks code " + "fullscreen insertdatetime media table paste code help wordcount spellchecker", + "toolbar": "undo redo | bold italic underline strikethrough | fontselect fontsizeselect formatselect | alignleft " + "aligncenter alignright alignjustify | outdent indent | numlist bullist checklist | forecolor " + "backcolor casechange permanentpen formatpainter removeformat | pagebreak | charmap emoticons | " + "fullscreen preview save print | insertfile image media pageembed template link anchor codesample | " + "code", +} diff --git a/symfexit/root/urls.py b/symfexit/root/urls.py index 9f6ac07..71afc43 100644 --- a/symfexit/root/urls.py +++ b/symfexit/root/urls.py @@ -22,6 +22,7 @@ # from symfexit.adminsite.admin import admin_site from symfexit.root.utils import enable_if +from symfexit.root.views import CustomPasswordResetView try: import django_browser_reload # noqa @@ -80,3 +81,8 @@ def chrome_devtools(request): + static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT), ) ) + +# Prepend the custom view to override the default password_reset URL +urlpatterns = [ + path("accounts/password_reset/", CustomPasswordResetView.as_view(), name="password_reset"), +] + list(urlpatterns) # keep the rest of the original list intact diff --git a/symfexit/root/views.py b/symfexit/root/views.py new file mode 100644 index 0000000..4982e2a --- /dev/null +++ b/symfexit/root/views.py @@ -0,0 +1,58 @@ +# symfexit/root/views.py +from django.contrib.auth.forms import PasswordResetForm +from django.contrib.auth.tokens import default_token_generator +from django.contrib.auth.views import PasswordResetView +from django.urls import reverse +from django.utils.encoding import force_bytes +from django.utils.http import urlsafe_base64_encode + +from symfexit.emails._templates.emails.password_request import PasswordResetEmail +from symfexit.emails._templates.render import send_email +from symfexit.members.admin import Member + + +class MyPasswordResetForm(PasswordResetForm): + def send_mail( + self, + subject_template_name, + email_template_name, + context, + from_email, + to_email, + html_email_template_name=None, + ): + # context = { + # "email": user_email, + # "domain": domain, + # "site_name": site_name, + # "uid": urlsafe_base64_encode(user_pk_bytes), + # "user": user, + # "token": token_generator.make_token(user), + # "protocol": "https" if use_https else "http", + # **(extra_email_context or {}), + # } + user: Member = context["user"] + reset_path = reverse( + "password_reset_confirm", # your URL‑conf name + kwargs={ + "uidb64": urlsafe_base64_encode(force_bytes(user.pk)), + "token": default_token_generator.make_token(user), + }, + ) + + # # Prepend scheme+host to make a full absolute URL + reset_url = f"{context['protocol']}://{context['domain']}{reset_path}" + send_email( + PasswordResetEmail( + { + "firstname": user.first_name, + "url": reset_url, + "email": user.email, + } + ), + recipient_list=[to_email], + ) + + +class CustomPasswordResetView(PasswordResetView): + form_class = MyPasswordResetForm diff --git a/symfexit/signup/views.py b/symfexit/signup/views.py index afbd0d2..8948d41 100644 --- a/symfexit/signup/views.py +++ b/symfexit/signup/views.py @@ -6,6 +6,8 @@ from django.urls import reverse, reverse_lazy from django.views.generic import FormView +from symfexit.emails._templates.emails.apply import ApplyEmail +from symfexit.emails._templates.render import send_email from symfexit.payments.models import Order from symfexit.payments.registry import payments_registry from symfexit.signup.forms import SignupForm @@ -22,6 +24,7 @@ class MemberSignup(FormView): def form_valid(self, form): logout(self.request) application = form.save() + send_email(ApplyEmail({"firstname": application.first_name}), application.email) return HttpResponseRedirect(reverse("signup:payment", args=[application.eid]))