Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Empty file added symfexit/emails/__init__.py
Empty file.
Empty file.
133 changes: 133 additions & 0 deletions symfexit/emails/_templates/base.py
Original file line number Diff line number Diff line change
@@ -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
Empty file.
27 changes: 27 additions & 0 deletions symfexit/emails/_templates/emails/apply.py
Original file line number Diff line number Diff line change
@@ -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
49 changes: 49 additions & 0 deletions symfexit/emails/_templates/emails/password_request.py
Original file line number Diff line number Diff line change
@@ -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 = """<p>You're receiving this email because you requested a password reset for your user account at {{ site_url }}.</p>

<p>Please go to the following page and choose a new password:</p>

<p><a href="{{url}}">{{url}}</a></p>

<p>In case you’ve forgotten, you are: {{email}}</p>

<p>Thanks for using our site!</p>

{{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),
]
Empty file.
8 changes: 8 additions & 0 deletions symfexit/emails/_templates/layouts/base.py
Original file line number Diff line number Diff line change
@@ -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"
34 changes: 34 additions & 0 deletions symfexit/emails/_templates/manager.py
Original file line number Diff line number Diff line change
@@ -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]
98 changes: 98 additions & 0 deletions symfexit/emails/_templates/render.py
Original file line number Diff line number Diff line change
@@ -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 = """<!DOCTYPE html>
<html>
<head>
<style>
@font-face {
font-family: 'HelveticaNeueLTStd';

font-weight: 500;
font-style: normal;
font-display: swap;
}
body {
font-family: 'HelveticaNeueLTStd';
}
</style>
</head>
<body>
{{ content }}
</body>
</html>"""

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))
Loading
Loading