Render every outgoing e-mail from an editable text template

The wording of the dunning and SEPA pre-notification mails was hard-coded in
Python, so adjusting a single sentence required a new release. Both texts now
live in plain text templates that ship as defaults, are copied into the store's
templates/mail/ directory on first start and can be edited there or under
Optionen -> E-Mail-Vorlagen; an existing file is never overwritten and a deleted
one is restored from the shipped default.

A template carries its subject in the first line and the body after a blank
line. Placeholders use the same {{ ... }} syntax as the document templates and
share their member/organization values, so a name means the same thing in a
letter and in the mail that carries it. Unknown placeholders are rejected while
editing instead of during a send run, a line holding nothing but placeholders
that render empty is dropped, and {{#claims}} ... {{/claims}} repeats per entry.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Marcel Peterkau
2026-08-28 20:49:08 +02:00
co-authored by Claude Opus 5
parent e3807bf7dc
commit d6cd58a788
18 changed files with 1017 additions and 167 deletions
+1
View File
@@ -129,6 +129,7 @@ def test_default_templates_are_seeded_without_overwriting_store_version(tmp_path
"Forderung mit Positionen.fodt",
"Mahnung.fodt",
"Mitglied.fodt",
"mail",
}
+105
View File
@@ -0,0 +1,105 @@
import pytest
from ccma.domain.mail_templates import (
MAIL_TEMPLATES,
MailTemplate,
MailTemplateError,
default_mail_template,
parse_mail_template,
render_mail_template,
serialize_mail_template,
validate_mail_template,
)
from ccma.storage.repository import MemberRepository, RepositoryError
def test_subject_header_round_trips_through_file_format() -> None:
text = serialize_mail_template("Hallo {{member.first_name}}", "Zeile eins\nZeile zwei")
template = parse_mail_template(text)
assert text.startswith("Betreff: Hallo {{member.first_name}}\n\n")
assert template == MailTemplate("Hallo {{member.first_name}}", "Zeile eins\nZeile zwei")
def test_shipped_defaults_only_use_declared_placeholders() -> None:
for spec in MAIL_TEMPLATES:
template = default_mail_template(spec.key)
validate_mail_template(spec.key, template.subject, template.body)
def test_rendering_fills_values_and_expands_repeat_blocks() -> None:
template = MailTemplate(
"Beitrag für {{member.first_name}}",
"Hallo {{member.first_name}},\n"
"{{#claims}}- {{claim.title}}: {{claim.amount}}{{/claims}}\n"
"Summe: {{claims.total}}",
)
rendered = render_mail_template(
template,
{"member.first_name": "Ada", "claims.total": "75.00"},
{
"claims": [
{"claim.title": "Aufnahmegebühr", "claim.amount": "15.00"},
{"claim.title": "Mitgliedsbeitrag", "claim.amount": "60.00"},
]
},
)
assert rendered.subject == "Beitrag für Ada"
assert rendered.body == (
"Hallo Ada,\n- Aufnahmegebühr: 15.00\n- Mitgliedsbeitrag: 60.00\nSumme: 75.00"
)
def test_line_holding_only_an_empty_placeholder_is_dropped() -> None:
template = MailTemplate("Betreff", "Erste Zeile\n{{reminder.hint}}\nLetzte Zeile")
assert render_mail_template(template, {"reminder.hint": ""}).body == "Erste Zeile\nLetzte Zeile"
assert (
render_mail_template(template, {"reminder.hint": "Hinweis: X"}).body
== "Erste Zeile\nHinweis: X\nLetzte Zeile"
)
def test_unknown_placeholder_is_rejected_while_editing() -> None:
with pytest.raises(MailTemplateError, match="member.favourite_colour"):
validate_mail_template("welcome", "Betreff", "Hallo {{member.favourite_colour}}")
def test_store_copies_are_seeded_editable_and_resettable(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
path = repository.mail_templates_root / "willkommen.txt"
assert path.is_file()
repository.save_mail_template("welcome", subject="Moin {{member.first_name}}", body="Kurz und knapp.")
assert repository.get_mail_template("welcome") == MailTemplate(
"Moin {{member.first_name}}", "Kurz und knapp."
)
assert "Moin {{member.first_name}}" in path.read_text(encoding="utf-8")
repository.reset_mail_template("welcome")
assert repository.get_mail_template("welcome") == default_mail_template("welcome")
def test_missing_template_file_falls_back_to_shipped_default(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
(repository.mail_templates_root / "mahnung.txt").unlink()
template = repository.get_mail_template("reminder")
assert template == default_mail_template("reminder")
assert (repository.mail_templates_root / "mahnung.txt").is_file()
def test_saving_a_broken_template_is_refused(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
with pytest.raises(RepositoryError, match="Platzhalter"):
repository.save_mail_template("reminder", subject="Mahnung", body="Hallo {{member.nonsense}}")
with pytest.raises(RepositoryError, match="Betreff"):
repository.save_mail_template("reminder", subject=" ", body="Hallo")
+3 -3
View File
@@ -178,11 +178,11 @@ def test_debit_mail_is_thunderbird_draft(tmp_path):
debit = pending_direct_debits(repository, due_until=date(2026, 12, 31))[0][0]
content = debit_mail_bytes(
recipient=member.email,
first_name=member.first_name,
repository,
member=member,
debit=debit,
collection_date=date(2026, 8, 3),
creditor_id="DE98ZZZ09999999999",
organization={"name": "C3MA", "creditor_id": "DE98ZZZ09999999999"},
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",