Guides

Templates

Templates keep email copy out of your code. Each one is a folder with a subject and a body, rendered by Jinja2 in a sandbox, with HTML escaping on and missing data treated as an error.

Folder layout

templates/
templates/
    _layouts/
        base.html          shared layout
    welcome/
        subject.txt        required
        body.html          html body, autoescaped
        body.txt           optional; generated from body.html when missing
    password-reset/
        subject.txt
        body.html

Point MailingKit at the folder with templates= or MAILINGKIT_TEMPLATES_DIR:

python
mail = MailingKit.from_env(templates="templates")

await mail.send_template("welcome", {"name": "Ada"}, to="ada@example.com")

Writing a template

welcome/subject.txt
Welcome to {{ product }}, {{ name }}
welcome/body.html
{% extends "_layouts/base.html" %}
{% block content %}
  <h1>Welcome, {{ name }}</h1>
  <p>Your {{ product }} workspace is ready.</p>
  <p><a href="{{ login_url }}">Open your workspace</a></p>
{% endblock %}

Any Jinja2 feature works: extends, include, loops, conditions and filters. Values are escaped in .html files, so a user called <script> is shown as text. Plain text files are not escaped.

Subjects have their whitespace collapsed to single spaces, so data can never put a line break into the Subject header.

Typed template data

Bind a template to a dataclass or a Pydantic model to check data before anything renders:

python
from dataclasses import dataclass
from mailingkit import Template

@dataclass
class CandidateSelected:
    candidate_name: str
    company_name: str
    next_round: str
    interview_url: str

CANDIDATE_SELECTED = Template("candidate-selected", CandidateSelected)

await mail.send_template(
    CANDIDATE_SELECTED,
    CandidateSelected(
        candidate_name="Grace",
        company_name="Acme",
        next_round="technical interview",
        interview_url="https://jobs.example.com/interview/123",
    ),
    to="grace@example.com",
)

You can also pass a plain dict. It is checked against the model first, and every problem is reported at once:

text
TemplateDataError: Template 'candidate-selected' data is invalid:
missing required fields: interview_url, next_round

Dataclasses catch missing and unknown fields. Pydantic models also validate types, so {"number": "abc"} for an int field fails clearly. Pydantic is never required: MailingKit uses it only when your model is a Pydantic model.

Untyped templates are still strict

Without a model, a template that uses a variable you did not pass raises TemplateDataError instead of rendering an empty string. Typed templates just catch the problem earlier and list every missing field.

Previewing

render() returns the subject and bodies without sending, which is useful for previews, admin screens and tests:

python
rendered = mail.render(CANDIDATE_SELECTED, data)
print(rendered.subject)
print(rendered.text)

Custom Jinja2 environments

For custom filters or a different loader, build a TemplateRenderer yourself:

python
from mailingkit import MailingKit, TemplateRenderer

renderer = TemplateRenderer("templates", globals={"support_email": "help@example.com"})
renderer.environment.filters["money"] = lambda cents: f"${cents / 100:,.2f}"

mail = MailingKit.from_env(templates=renderer)

Passing your own environment= is also supported. In that case autoescaping and strict undefined handling are your responsibility.

Errors

ErrorWhen
TemplateNotFoundErrorNo subject.txt, or no body file.
TemplateDataErrorData is missing, unknown, of the wrong type, or a variable is undefined.
TemplateRenderErrorA syntax error, or the sandbox blocked an unsafe operation.