-
Notifications
You must be signed in to change notification settings - Fork 0
Email templating engine #34
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: feat/email-service-async
Are you sure you want to change the base?
Changes from all commits
3f09790
0e309d5
fc5371d
164fb22
abd2932
677de7f
10b02ae
270315a
4201a54
dee7352
a4e7079
1a8c486
939e560
49919f3
3502681
73c6c7f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1 +1 @@ | ||
| 3.12.9 | ||
| 3.13.8 |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,54 +1,34 @@ | ||
| from enum import Enum | ||
| from string import Template | ||
|
|
||
| from app import templates | ||
| from app.core.config import settings | ||
| from app.users.schemas.user_schema import UserInDB | ||
|
|
||
| from app.emails._global_state import get_client | ||
| from app.emails.clients.base import BaseEmailClient | ||
| from app.emails.schema.email import Email, EmailContext | ||
| from app.emails._global_state import get_client | ||
|
|
||
|
|
||
| class Paths(Enum): | ||
| NEW_USER = "app/emails/templates/welcome_email.html" | ||
|
|
||
|
|
||
| class EmailService: | ||
| def __init__(self, email_client: BaseEmailClient | None = None): | ||
| self.email_client = email_client or get_client() | ||
|
|
||
| def _get_email( | ||
| self, | ||
| recipient_email: str, | ||
| template: str, | ||
| subject: str, | ||
| html_message_input: dict | None = None, | ||
| ) -> Email: | ||
| with open(template, "r") as file: | ||
| html_template_string = file.read() | ||
|
|
||
| html_message_input = html_message_input or {} | ||
| html = Template(html_template_string).substitute(**html_message_input) | ||
|
|
||
| return Email( | ||
| to_emails=[recipient_email], | ||
| subject=subject, | ||
| html=html, | ||
| ) | ||
| self.template_service = templates.TemplatesService() | ||
|
|
||
| def send_new_user_email( | ||
| self, | ||
| user: UserInDB, | ||
| ) -> None: | ||
| email = self._get_email( | ||
| user.email, | ||
| Paths.NEW_USER.value, | ||
| "Welcome", | ||
| ) | ||
|
|
||
| email.context = EmailContext( | ||
| max_retries=settings.SEND_WELCOME_EMAIL_MAX_RETRIES, | ||
| backoff_in_seconds=settings.SEND_WELCOME_EMAIL_RETRY_BACKOFF_VALUE, | ||
| error_message=f"Sending new user email to user {user.id} failed", | ||
| from app.users.schemas.templates import NewUserTemplate | ||
|
|
||
| template = NewUserTemplate(name=user.email) | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I'm not sure about the template not being inyectable here. Is there a specific reason?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. I totally agree with you. I want to add that functionality on a later refactor, to clean up the email service so that it is closed for modification (which it currently is not, as it basically requires a new method for each email type). I didn't do it here because I didn't want to scope creep this PR. Would you be ok with working on that on a different PR? |
||
|
|
||
| email = Email( | ||
| to_emails=[user.email], | ||
| subject="Welcome", | ||
| html=self.template_service.render(template), | ||
| context=EmailContext( | ||
| max_retries=settings.SEND_WELCOME_EMAIL_MAX_RETRIES, | ||
| backoff_in_seconds=settings.SEND_WELCOME_EMAIL_RETRY_BACKOFF_VALUE, | ||
| error_message=f"Sending new user email to user {user.id} failed", | ||
| ), | ||
| ) | ||
|
|
||
| self.email_client.send_email(email) | ||
|
|
@@ -57,10 +37,14 @@ def send_user_remind_email( | |
| self, | ||
| user: UserInDB, | ||
| ) -> None: | ||
| email = self._get_email( | ||
| user.email, | ||
| Paths.NEW_USER.value, | ||
| "Welcome", | ||
| from app.users.schemas.templates import NewUserTemplate | ||
|
||
|
|
||
| template = NewUserTemplate(name=user.email) | ||
|
|
||
| email = Email( | ||
| to_emails=[user.email], | ||
| subject="Welcome", | ||
| html=self.template_service.render(template), | ||
| ) | ||
|
|
||
| self.email_client.send_email(email) | ||
This file was deleted.
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| from . import types | ||
| from . import exceptions | ||
| from . import utils | ||
|
|
||
| from .schemas import BaseTemplate, BaseEmailTemplate | ||
| from .services import TemplatesService |
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,2 @@ | ||||||||||||||||
| from .invalid_template_path_exception import InvalidTemplatePathException | ||||||||||||||||
| from .template_missing_path_exception import TemplateMissingPathException | ||||||||||||||||
|
||||||||||||||||
| from .template_missing_path_exception import TemplateMissingPathException | |
| from .template_missing_path_exception import TemplateMissingPathException | |
| __all__ = [ | |
| "InvalidTemplatePathException", | |
| "TemplateMissingPathException", | |
| ] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,12 @@ | ||
| from typing import TYPE_CHECKING | ||
|
|
||
| if TYPE_CHECKING: | ||
| from app.templates import BaseTemplate | ||
|
|
||
|
|
||
| class InvalidTemplatePathException(Exception): | ||
| def __init__(self, obj: "BaseTemplate") -> None: | ||
| cls_name = obj.__class__.__name__ | ||
| path = obj.get_path() | ||
| self.message = msg = f"{cls_name}: couldn't find template at {path}." | ||
| super().__init__(msg) |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,25 @@ | ||||||
| from textwrap import dedent | ||||||
| from typing import TYPE_CHECKING | ||||||
|
|
||||||
| if TYPE_CHECKING: | ||||||
| from app.templates import BaseTemplate | ||||||
|
|
||||||
|
|
||||||
| class TemplateMissingPathException(Exception): | ||||||
| def __init__(self, obj: "BaseTemplate") -> None: | ||||||
| cls_name = obj.__class__.__name__ | ||||||
| self.message = message = dedent( | ||||||
| f""" | ||||||
| {cls_name} does not have a path configured. | ||||||
|
|
||||||
| Set the path to the template file in the class definition, when | ||||||
| inheriting from BaseTemplate or BaseEmailTemplate. | ||||||
|
|
||||||
| Example: | ||||||
| ``` | ||||||
| class {cls_name}(BaseTemplate, path="path/to/template.mj"): | ||||||
|
||||||
| class {cls_name}(BaseTemplate, path="path/to/template.mj"): | |
| class {cls_name}(BaseTemplate, path="path/to/template.j2"): |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| from .base_template import BaseTemplate | ||
| from .base_email_template import BaseEmailTemplate | ||
|
|
||
| __all__ = ( | ||
| "BaseTemplate", | ||
| "BaseEmailTemplate", | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,13 @@ | ||
| from abc import ABC | ||
|
|
||
| from app.templates import utils | ||
|
|
||
| from .base_template import BaseTemplate | ||
|
|
||
|
|
||
| class BaseEmailTemplate( | ||
| BaseTemplate, | ||
| ABC, | ||
| pipeline=(utils.render_mjml,), | ||
| ): | ||
| pass |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,38 @@ | ||
| from abc import ABC | ||
| from typing import Any, ClassVar | ||
|
|
||
| import pydantic | ||
|
|
||
| from app.templates import exceptions, types | ||
|
|
||
|
|
||
| class BaseTemplate(pydantic.BaseModel, ABC): | ||
| __template_path__: ClassVar[str | None] = None | ||
| __template_pipeline__: ClassVar[types.ProcessingPipeline | None] = None | ||
|
|
||
| @classmethod | ||
| def __init_subclass__( | ||
| cls, | ||
| path: str | None = None, | ||
| pipeline: types.ProcessingPipeline | None = None, | ||
| **kw: Any, | ||
| ) -> None: | ||
| super().__init_subclass__(**kw) | ||
|
|
||
| if path: | ||
| cls.__template_path__ = path | ||
|
|
||
| if pipeline: | ||
| cls.__template_pipeline__ = pipeline | ||
|
|
||
| def get_path(self) -> str: | ||
| if self.__template_path__: | ||
| return self.__template_path__ | ||
|
|
||
| raise exceptions.TemplateMissingPathException(self) | ||
|
|
||
| def get_pipeline(self) -> types.ProcessingPipeline: | ||
| return self.__template_pipeline__ or tuple() | ||
|
|
||
| def get_args(self) -> dict[str, Any]: | ||
| return self.model_dump(exclude="path") |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| from .templates_service import TemplatesService | ||
|
|
||
| __all__ = ( | ||
| "TemplatesService", | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,47 @@ | ||
| import itertools | ||
| from typing import ClassVar | ||
|
|
||
| import jinja2 | ||
|
|
||
| from app.templates import exceptions, schemas, types | ||
|
|
||
|
|
||
| class TemplatesService: | ||
| ENVIRONMENT: ClassVar[jinja2.Environment] = jinja2.Environment( | ||
| loader=jinja2.FileSystemLoader("assets/templates/"), | ||
| trim_blocks=True, | ||
| lstrip_blocks=True, | ||
| ) | ||
|
|
||
| def __init__( | ||
| self, | ||
| *, | ||
| environment: jinja2.Environment | None = None, | ||
| pipeline: types.ProcessingPipeline | None = None, | ||
| ) -> None: | ||
| self._environment = environment or self.ENVIRONMENT | ||
| self._pipeline = pipeline | ||
|
|
||
| def render( | ||
| self, | ||
| template: schemas.BaseTemplate, | ||
| *, | ||
| pipeline: types.ProcessingPipeline | None = None, | ||
| ) -> str: | ||
| path, args = template.get_path(), template.get_args() | ||
|
|
||
| try: | ||
| jinja_template = self._environment.get_template(path) | ||
| except jinja2.exceptions.TemplateNotFound: | ||
| raise exceptions.InvalidTemplatePathException(template) | ||
|
|
||
| rendered_template = jinja_template.render(**args) | ||
|
|
||
| for processor in itertools.chain( | ||
| template.get_pipeline(), | ||
| pipeline or [], | ||
| self._pipeline or [], | ||
| ): | ||
| rendered_template = processor(rendered_template) | ||
|
|
||
| return rendered_template |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,6 @@ | ||
| from .processor import Processor, ProcessingPipeline | ||
|
|
||
| __all__ = ( | ||
| "Processor", | ||
| "ProcessingPipeline", | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| from typing import Callable | ||
|
|
||
| type Processor = Callable[[str], str] | ||
| type ProcessingPipeline = tuple[Processor, ...] |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| from .mjml_renderer import render_mjml | ||
|
|
||
| __all__ = ( | ||
| "render_mjml", | ||
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| import mjml | ||
|
|
||
|
|
||
| def render_mjml(input: str) -> str: | ||
| return mjml.mjml2html(input, disable_comments=True) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,5 @@ | ||
| from app import templates | ||
|
|
||
|
|
||
| class NewUserTemplate(templates.BaseEmailTemplate, path="new_user_email.j2"): | ||
| name: str |
| Original file line number | Diff line number | Diff line change | ||||
|---|---|---|---|---|---|---|
| @@ -0,0 +1,31 @@ | ||||||
| {% from "constants/theme.j2" import theme %} | ||||||
| {% from "utils/render_attributes.j2" import render_attributes %} | ||||||
|
|
||||||
| {% macro text(variant='h1', styles={}) %} | ||||||
|
||||||
| {% macro text(variant='h1', styles={}) %} | |
| {% macro heading(variant='h1', styles={}) %} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,17 @@ | ||
| {% from "constants/theme.j2" import theme %} | ||
| {% from "utils/render_attributes.j2" import render_attributes %} | ||
|
|
||
| {% macro image(src, alt='', styles={}) %} | ||
| {% set defaults = { | ||
| 'width': 'auto', | ||
| 'align': 'center', | ||
| 'padding-top': theme.spacing.lg, | ||
| 'padding-bottom': theme.spacing.lg, | ||
| } %} | ||
|
|
||
| <mj-image | ||
| src="{{ src }}" | ||
| alt="{{ alt }}" | ||
| {{ render_attributes(defaults, styles) }} | ||
| /> | ||
| {% endmacro %} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The import statement is placed inside the method rather than at the module level. Move this import to the top of the file with other imports to follow Python conventions and improve code clarity.