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
2 changes: 1 addition & 1 deletion .python-version
Original file line number Diff line number Diff line change
@@ -1 +1 @@
3.12.9
3.13.8
4 changes: 3 additions & 1 deletion app/core/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,9 @@ class Settings(BaseSettings):
frozen=True,
)
# APP
RUN_ENV: Literal["local", "develop", "staging", "production"] = "local"
RUN_ENV: Literal["local", "develop", "staging", "production", "test"] = (
"local"
)
PROCESS_TYPE: Literal["api", "worker", "beat"]
API_V1_STR: str = "/api/v1"
SECRET_KEY: str = secrets.token_urlsafe(32)
Expand Down
66 changes: 25 additions & 41 deletions app/emails/services/emails_service.py
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

Copilot AI Jan 26, 2026

Copy link

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.

Copilot uses AI. Check for mistakes.

template = NewUserTemplate(name=user.email)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The 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?
I'm thinking about maybe A/B testing where you want to send 2 different new user emails to users depending on X factor.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The 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)
Expand All @@ -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

Copilot AI Jan 26, 2026

Copy link

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.

Copilot uses AI. Check for mistakes.

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)
36 changes: 0 additions & 36 deletions app/emails/templates/welcome_email.html

This file was deleted.

6 changes: 6 additions & 0 deletions app/templates/__init__.py
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
2 changes: 2 additions & 0 deletions app/templates/exceptions/__init__.py
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

Copilot AI Jan 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Missing all definition. Add all to explicitly declare the public API, consistent with other modules in the codebase.

Suggested change
from .template_missing_path_exception import TemplateMissingPathException
from .template_missing_path_exception import TemplateMissingPathException
__all__ = [
"InvalidTemplatePathException",
"TemplateMissingPathException",
]

Copilot uses AI. Check for mistakes.
12 changes: 12 additions & 0 deletions app/templates/exceptions/invalid_template_path_exception.py
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)
25 changes: 25 additions & 0 deletions app/templates/exceptions/template_missing_path_exception.py
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"):

Copilot AI Jan 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Corrected file extension from 'mj' to 'j2' in the example.

Suggested change
class {cls_name}(BaseTemplate, path="path/to/template.mj"):
class {cls_name}(BaseTemplate, path="path/to/template.j2"):

Copilot uses AI. Check for mistakes.
...
```
"""
)
super().__init__(message)
7 changes: 7 additions & 0 deletions app/templates/schemas/__init__.py
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",
)
13 changes: 13 additions & 0 deletions app/templates/schemas/base_email_template.py
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
38 changes: 38 additions & 0 deletions app/templates/schemas/base_template.py
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")
5 changes: 5 additions & 0 deletions app/templates/services/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from .templates_service import TemplatesService

__all__ = (
"TemplatesService",
)
47 changes: 47 additions & 0 deletions app/templates/services/templates_service.py
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
6 changes: 6 additions & 0 deletions app/templates/types/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from .processor import Processor, ProcessingPipeline

__all__ = (
"Processor",
"ProcessingPipeline",
)
4 changes: 4 additions & 0 deletions app/templates/types/processor.py
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, ...]
5 changes: 5 additions & 0 deletions app/templates/utils/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
from .mjml_renderer import render_mjml

__all__ = (
"render_mjml",
)
5 changes: 5 additions & 0 deletions app/templates/utils/mjml_renderer.py
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)
5 changes: 5 additions & 0 deletions app/users/schemas/templates.py
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
31 changes: 31 additions & 0 deletions assets/templates/components/atoms/heading.j2
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={}) %}

Copilot AI Jan 26, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The macro is named 'text' but the file is 'heading.j2' and it renders heading variants (h1, h2, h3). The macro name should be 'heading' to match the file name and its purpose.

Suggested change
{% macro text(variant='h1', styles={}) %}
{% macro heading(variant='h1', styles={}) %}

Copilot uses AI. Check for mistakes.

{% set defaults = {
'color': theme.colors.primary,
'font-family': theme.typography.family,
'font-weight': theme.typography.weights.bold,
'line-height': theme.typography.line_heights.normal,
'padding-bottom': theme.spacing.lg,
'align': 'left',
} %}

{% set variants = {
'h1': {
'font-size': theme.typography.sizes.xxl,
},
'h2': {
'font-size': theme.typography.sizes.xl,
},
'h3': {
'font-size': theme.typography.sizes.lg,
'padding-bottom': theme.spacing.md,
},
} %}

<mj-text {{ render_attributes(defaults, variants.get(variant), styles) }}>
{{ caller() }}
</mj-text>
{% endmacro %}
17 changes: 17 additions & 0 deletions assets/templates/components/atoms/image.j2
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 %}
Loading