mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 03:20:49 +02:00
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:
co-authored by
Claude Opus 5
parent
e3807bf7dc
commit
d6cd58a788
@@ -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
|
||||
)
|
||||
@@ -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", "")),
|
||||
}
|
||||
Reference in New Issue
Block a user