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
+292
View File
@@ -0,0 +1,292 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from pathlib import Path
SUBJECT_HEADER = "Betreff:"
BUILTIN_TEMPLATE_ROOT = Path(__file__).resolve().parent.parent / "assets" / "mail_templates"
PLACEHOLDER_PATTERN = re.compile(r"\{\{\s*([a-z][a-z0-9_.]*)\s*\}\}", re.IGNORECASE)
# A repeat block either wraps a single line ("{{#claims}}- {{claim.title}}{{/claims}}")
# or spans several lines with the markers on their own lines; both render one copy of
# the block per entry.
BLOCK_PATTERN = re.compile(
r"\{\{\s*#\s*([a-z][a-z0-9_.]*)\s*\}\}[ \t]*\n?(.*?)\{\{\s*/\s*\1\s*\}\}[ \t]*(\n?)",
re.IGNORECASE | re.DOTALL,
)
class MailTemplateError(RuntimeError):
pass
@dataclass(frozen=True, slots=True)
class MailTemplate:
subject: str
body: str
@dataclass(frozen=True, slots=True)
class MailTemplateSpec:
key: str
label: str
filename: str
description: str
# Placeholder name -> short explanation, shown as editing help in the options
# dialog. The mail services must supply exactly these keys, so this doubles as
# the contract a stored template is validated against.
placeholders: tuple[tuple[str, str], ...]
# Repeat block name -> (explanation, item placeholders).
blocks: tuple[tuple[str, str, tuple[tuple[str, str], ...]], ...] = ()
BASE_PLACEHOLDERS: tuple[tuple[str, str], ...] = (
("member.first_name", "Vorname"),
("member.last_name", "Nachname"),
("member.full_name", "Vor- und Nachname"),
("member.nickname", "Nickname"),
("member.number", "Mitgliedsnummer"),
("member.email", "E-Mail-Adresse"),
("member.status", "Mitgliedsstatus"),
("member.accepted_at", "Datum des Aufnahmebeschlusses"),
("member.started_at", "Mitglied seit"),
("member.application_date", "Antragsdatum"),
("member.payment_frequency", "Zahlweise"),
("member.address_line", "Anschrift in einer Zeile"),
("member.iban", "IBAN des Mitglieds"),
("member.mandate_reference", "Mandatsreferenz"),
("member.mandate_active", "Lastschriftmandat aktiv (Ja/Nein)"),
("organization.name", "Vereinsname"),
("organization.address_line", "Anschrift des Vereins"),
("organization.email", "E-Mail-Adresse des Vereins"),
("organization.website", "Website des Vereins"),
("organization.iban", "IBAN des Vereins"),
("organization.bic", "BIC des Vereins"),
("organization.creditor_id", "Gläubiger-Identifikationsnummer"),
("current_date", "Heutiges Datum"),
("signature", "Signatur aus dem Versandfenster"),
)
CLAIM_ITEM_PLACEHOLDERS: tuple[tuple[str, str], ...] = (
("claim.title", "Bezeichnung der Forderung"),
("claim.description", "Beschreibung inkl. abgerechnetem Zeitraum"),
("claim.due_date", "Fälligkeitsdatum"),
("claim.amount", "Gesamtbetrag der Forderung"),
("claim.balance", "Noch offener Betrag"),
)
MAIL_TEMPLATES: tuple[MailTemplateSpec, ...] = (
MailTemplateSpec(
key="welcome",
label="Willkommen & Erstrechnung",
filename="willkommen.txt",
description=(
"Begrüßung neuer Mitglieder mit Mitgliedsnummer, Beginn der Mitgliedschaft "
"und den ersten offenen Forderungen (Aufnahmegebühr und erster Beitrag)."
),
placeholders=BASE_PLACEHOLDERS
+ (
(
"membership.start_date",
"Beginn der Mitgliedschaft; ist noch keiner erfasst, der 1. des auf den "
"Aufnahmebeschluss folgenden Monats",
),
("claims.list", "Alle ausgewählten Forderungen als Liste, eine je Zeile"),
("claims.total", "Summe der ausgewählten Forderungen"),
("claims.count", "Anzahl der ausgewählten Forderungen"),
("claims.first_due_date", "Früheste Fälligkeit der ausgewählten Forderungen"),
("payment.instructions", "Zahlungshinweis passend zum Lastschriftmandat"),
("payment.reference", "Vorgeschlagener Verwendungszweck"),
),
blocks=(
(
"claims",
"Wiederholt sich je ausgewählter Forderung",
CLAIM_ITEM_PLACEHOLDERS,
),
),
),
MailTemplateSpec(
key="reminder",
label="Mahnung",
filename="mahnung.txt",
description=(
"Begleitmail zu einer Mahnung bzw. Zahlungserinnerung. Wird beim Versand "
"mit der jeweiligen Mahnstufe, Frist und den Mahnpositionen gefüllt."
),
placeholders=BASE_PLACEHOLDERS
+ CLAIM_ITEM_PLACEHOLDERS
+ (
("reminder.name", "Bezeichnung der Mahnstufe"),
("reminder.level", "Nummer der Mahnstufe"),
("reminder.payment_deadline", "Neue Zahlungsfrist"),
("reminder.detail", "Freitext-Hinweis der Mahnung"),
("reminder.hint", "Hinweiszeile, nur gefüllt wenn ein Freitext hinterlegt ist"),
("reminder.items", "Mahnpositionen als Liste, eine je Zeile"),
("reminder.fee_total", "Summe der Mahnpositionen"),
("payment.instructions", "Zahlungshinweis passend zum Lastschriftmandat"),
("payment.reference", "Vorgeschlagener Verwendungszweck"),
),
blocks=(
(
"reminder.items",
"Wiederholt sich je Mahnposition",
(
("item.description", "Bezeichnung der Position"),
("item.amount", "Betrag der Position"),
),
),
),
),
MailTemplateSpec(
key="sepa",
label="SEPA-Vorabinformation",
filename="sepa-info.txt",
description=(
"Vorabinformation zu einem geplanten Lastschrifteinzug mit Betrag, "
"Einzugsdatum und Mandatsdaten."
),
placeholders=BASE_PLACEHOLDERS
+ (
("debit.amount", "Einzuziehender Betrag"),
("debit.collection_date", "Einzugsdatum"),
("debit.purpose", "Verwendungszweck der Lastschrift"),
("debit.mandate_reference", "Mandatsreferenz"),
),
),
)
def template_spec(key: str) -> MailTemplateSpec:
for spec in MAIL_TEMPLATES:
if spec.key == key:
return spec
raise MailTemplateError(f"Unbekannte E-Mail-Vorlage: {key}")
def default_mail_template(key: str) -> MailTemplate:
spec = template_spec(key)
path = BUILTIN_TEMPLATE_ROOT / spec.filename
try:
return parse_mail_template(path.read_text(encoding="utf-8"))
except OSError as exc:
raise MailTemplateError(
f"Die mitgelieferte Vorlage „{spec.label}“ konnte nicht gelesen werden: {exc}"
) from exc
def parse_mail_template(text: str) -> MailTemplate:
"""Templates are plain text files whose first line carries the subject
("Betreff: ..."), followed by a blank line and the message body -- readable and
editable with any text editor, just like the rest of the store."""
lines = text.replace("\r\n", "\n").replace("\r", "\n").split("\n")
subject = ""
if lines and lines[0].strip().casefold().startswith(SUBJECT_HEADER.casefold()):
subject = lines[0].split(":", 1)[1].strip()
lines = lines[1:]
if lines and not lines[0].strip():
lines = lines[1:]
return MailTemplate(subject, "\n".join(lines).strip("\n"))
def serialize_mail_template(subject: str, body: str) -> str:
normalized = body.replace("\r\n", "\n").replace("\r", "\n").strip("\n")
return f"{SUBJECT_HEADER} {subject.strip()}\n\n{normalized}\n"
def render_mail_template(
template: MailTemplate,
values: dict[str, str],
repeats: dict[str, list[dict[str, str]]] | None = None,
) -> MailTemplate:
subject = _render_line(template.subject, values)
body = _render_blocks(template.body, values, repeats or {})
return MailTemplate(subject.strip(), _render_text(body, values))
def validate_mail_template(key: str, subject: str, body: str) -> None:
"""Rejects placeholders the mail service will never supply, so a typo surfaces
while editing the template instead of during a send run."""
spec = template_spec(key)
known = {name for name, _help in spec.placeholders}
block_names = {name for name, _help, _items in spec.blocks}
unknown: set[str] = set()
for block_name, _help, item_placeholders in spec.blocks:
for match in _blocks_of(body, block_name):
unknown.update(
name
for name in _placeholder_names(match)
if name not in known and name not in {item for item, _text in item_placeholders}
)
remaining = body
for block_name in block_names:
remaining = _strip_blocks(remaining, block_name)
unknown.update(name for name in _placeholder_names(remaining) if name not in known)
unknown.update(name for name in _placeholder_names(subject) if name not in known)
if unknown:
raise MailTemplateError(
"Unbekannte Platzhalter in der Vorlage: " + ", ".join(sorted(unknown))
)
if not subject.strip():
raise MailTemplateError("Die Vorlage benötigt einen Betreff.")
if not body.strip():
raise MailTemplateError("Die Vorlage benötigt einen Text.")
def _placeholder_names(text: str) -> set[str]:
return {match.group(1) for match in PLACEHOLDER_PATTERN.finditer(text)}
def _blocks_of(text: str, name: str) -> list[str]:
return [
match.group(2)
for match in BLOCK_PATTERN.finditer(text)
if match.group(1).casefold() == name.casefold()
]
def _strip_blocks(text: str, name: str) -> str:
return BLOCK_PATTERN.sub(
lambda match: "" if match.group(1).casefold() == name.casefold() else match.group(0), text
)
def _render_blocks(
body: str, values: dict[str, str], repeats: dict[str, list[dict[str, str]]]
) -> str:
def expand(match: re.Match[str]) -> str:
name = match.group(1)
entries = next(
(rows for key, rows in repeats.items() if key.casefold() == name.casefold()), None
)
if entries is None:
return match.group(0)
block = match.group(2)
if block.endswith("\n"):
block = block[:-1]
rendered = [_render_text(block, {**values, **entry}) for entry in entries]
if not rendered:
return ""
return "\n".join(rendered) + match.group(3)
return BLOCK_PATTERN.sub(expand, body)
def _render_text(text: str, values: dict[str, str]) -> str:
# A line that holds nothing but placeholders which all render empty (an optional
# hint, an empty list) is dropped instead of leaving a stray blank line behind.
rendered = []
for line in text.split("\n"):
replaced = _render_line(line, values)
if replaced.strip() or not line.strip() or not _placeholder_names(line):
rendered.append(replaced)
continue
if PLACEHOLDER_PATTERN.sub("", line).strip():
rendered.append(replaced)
return "\n".join(rendered)
def _render_line(line: str, values: dict[str, str]) -> str:
return PLACEHOLDER_PATTERN.sub(
lambda match: values.get(match.group(1), match.group(0)), line
)
+80
View File
@@ -0,0 +1,80 @@
from __future__ import annotations
from datetime import datetime
from ccma.domain.dates import format_date_for_display
from ccma.domain.models import (
MEMBERSHIP_STATUS_LABELS,
PAYMENT_FREQUENCY_LABELS,
Member,
)
def base_placeholder_values(
member: Member,
organization: dict | None = None,
*,
created_at: datetime | None = None,
) -> dict[str, str]:
"""Member and organization placeholders shared by document templates and mail
templates, so the same `{{member.*}}`/`{{organization.*}}` names mean the same
thing in a dunning letter and in the e-mail that carries it."""
organization = organization if isinstance(organization, dict) else {}
timestamp = created_at or datetime.now().astimezone()
organization_address = " ".join(
part
for part in (
str(organization.get("street", "")),
str(organization.get("postal_code", "")),
str(organization.get("city", "")),
)
if part
)
return {
"current_date": format_date_for_display(timestamp.date().isoformat()),
"current_datetime": timestamp.strftime("%d.%m.%Y %H:%M"),
"member.id": member.member_id,
"member.number": member.member_number,
"member.first_name": member.first_name,
"member.last_name": member.last_name,
"member.nickname": member.nickname,
"member.full_name": member.display_name,
"member.email": member.email,
"member.phone": member.phone,
"member.birth_date": format_date_for_display(member.birth_date),
"member.status": MEMBERSHIP_STATUS_LABELS.get(member.status, member.status),
"member.accepted_at": format_date_for_display(member.accepted_at),
"member.started_at": format_date_for_display(member.membership_started_at),
"member.ended_at": format_date_for_display(member.membership_ended_at),
"member.application_date": format_date_for_display(member.application_date),
"member.payment_frequency": PAYMENT_FREQUENCY_LABELS.get(
member.payment_frequency, member.payment_frequency
).capitalize(),
"member.street": member.street,
"member.address_addition": member.address_addition,
"member.postal_code": member.postal_code,
"member.city": member.city,
"member.country": member.country,
"member.address_line": " ".join(
part for part in (member.street, member.postal_code, member.city) if part
),
"member.account_holder": member.account_holder,
"member.iban": member.iban,
"member.bic": member.bic,
"member.mandate_reference": member.mandate_reference,
"member.mandate_signed_at": format_date_for_display(member.mandate_signed_at),
"member.mandate_revoked_at": format_date_for_display(member.mandate_revoked_at),
"member.mandate_active": "Ja" if member.mandate_active else "Nein",
"organization.name": str(organization.get("name", "")),
"organization.street": str(organization.get("street", "")),
"organization.postal_code": str(organization.get("postal_code", "")),
"organization.city": str(organization.get("city", "")),
"organization.country": str(organization.get("country", "")),
"organization.address_line": organization_address,
"organization.email": str(organization.get("email", "")),
"organization.phone": str(organization.get("phone", "")),
"organization.website": str(organization.get("website", "")),
"organization.iban": str(organization.get("iban", "")),
"organization.bic": str(organization.get("bic", "")),
"organization.creditor_id": str(organization.get("creditor_id", "")),
}