from __future__ import annotations
from importlib import resources
from pathlib import Path
from typing import TYPE_CHECKING
from jinja2 import (
BaseLoader,
ChoiceLoader,
Environment,
FileSystemLoader,
PackageLoader,
select_autoescape,
)
from regstack.email.base import EmailMessage
if TYPE_CHECKING:
from regstack.config.schema import EmailConfig
from regstack.models.user import BaseUser
_DEFAULT_PACKAGE = "regstack.email"
_DEFAULT_TEMPLATE_DIR = "templates"
[docs]
class MailComposer:
"""Builds rendered ``EmailMessage`` instances from Jinja2 templates.
Hosts override the default templates by registering their own template
directory via ``RegStack.add_template_dir``; the underlying
``ChoiceLoader`` resolves the host directory first.
Each email kind has three template files:
``<name>.subject.txt`` — single line, whitespace-stripped
``<name>.html`` — rich body
``<name>.txt`` — plain-text fallback
"""
def __init__(
self,
*,
email_config: EmailConfig,
app_name: str,
host_template_dirs: list[Path] | None = None,
) -> None:
self._email_config = email_config
self._app_name = app_name
self._host_dirs: list[Path] = list(host_template_dirs or [])
self._env = self._build_env()
[docs]
def add_template_dir(self, path: Path) -> None:
self._host_dirs.insert(0, path)
self._env = self._build_env()
def _build_env(self) -> Environment:
loaders: list[BaseLoader] = [FileSystemLoader(str(p)) for p in self._host_dirs]
loaders.append(PackageLoader(_DEFAULT_PACKAGE, _DEFAULT_TEMPLATE_DIR))
return Environment(
loader=ChoiceLoader(loaders),
autoescape=select_autoescape(["html"]),
trim_blocks=True,
lstrip_blocks=True,
keep_trailing_newline=True,
)
def _render(self, template_name: str, context: dict[str, object]) -> str:
return self._env.get_template(template_name).render(context)
def _compose(
self,
*,
kind: str,
to: str,
context: dict[str, object],
) -> EmailMessage:
subject = self._render(f"{kind}.subject.txt", context).strip()
html = self._render(f"{kind}.html", context)
text = self._render(f"{kind}.txt", context)
return EmailMessage(
to=to,
subject=subject,
html=html,
text=text,
from_address=self._email_config.from_address,
# An unset ``from_name`` defers to ``app_name`` so changing one
# knob is enough to rebrand outgoing emails.
from_name=self._email_config.from_name or self._app_name,
)
# --- Public renderers -------------------------------------------------
[docs]
def verification(
self, *, to: str, full_name: str | None, url: str, ttl_hours: int
) -> EmailMessage:
return self._compose(
kind="verification",
to=to,
context={
"app_name": self._app_name,
"full_name": full_name or "",
"url": url,
"ttl_hours": ttl_hours,
},
)
[docs]
def password_reset(
self, *, to: str, full_name: str | None, url: str, ttl_minutes: int
) -> EmailMessage:
return self._compose(
kind="password_reset",
to=to,
context={
"app_name": self._app_name,
"full_name": full_name or "",
"url": url,
"ttl_minutes": ttl_minutes,
},
)
[docs]
def email_change(
self, *, to: str, full_name: str | None, url: str, ttl_minutes: int
) -> EmailMessage:
return self._compose(
kind="email_change",
to=to,
context={
"app_name": self._app_name,
"full_name": full_name or "",
"url": url,
"ttl_minutes": ttl_minutes,
},
)
# SMS bodies live here too — same Jinja loader stack so hosts can
# override the wording by dropping ``sms_<kind>.txt`` into their
# template directory.
[docs]
def sms_body(self, *, kind: str, **context: object) -> str:
full = {"app_name": self._app_name, **context}
return self._render(f"sms_{kind}.txt", full).strip()
def default_template_dir() -> Path:
"""Filesystem path of the bundled defaults — useful for tooling that wants
to copy and customise rather than override per-template.
"""
return Path(str(resources.files(_DEFAULT_PACKAGE).joinpath(_DEFAULT_TEMPLATE_DIR)))
__all__ = ["MailComposer", "default_template_dir"]
def for_user(user: BaseUser) -> dict[str, object]:
"""Tiny convenience used by routers building template contexts."""
return {"email": user.email, "full_name": user.full_name}