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
+178
View File
@@ -7,6 +7,12 @@ from tkinter import filedialog, messagebox, ttk
from ccma.config import AppConfig
from ccma.domain.contributions import decimal_value, money_text
from ccma.domain.mail_templates import (
MAIL_TEMPLATES,
MailTemplateError,
default_mail_template,
template_spec,
)
from ccma.domain.models import HOUSEKEEPER_MEMBER_FIELD_LABELS
from ccma.services.intervals import (
IntervalValidationError,
@@ -67,6 +73,14 @@ class OptionsDialog(tk.Toplevel):
"website", "iban", "bic", "creditor_id",
)
}
# Mail templates are edited one at a time but saved together with the rest of
# the options, so the edits of every tab are kept in memory until "Speichern".
self.mail_template_key = MAIL_TEMPLATES[0].key
self.mail_template_edits: dict[str, tuple[str, str]] = {}
self.mail_template_original: dict[str, tuple[str, str]] = {}
self.mail_template_var = tk.StringVar(value=MAIL_TEMPLATES[0].label)
self.mail_template_subject_var = tk.StringVar()
self.mail_template_hint_var = tk.StringVar()
self.title("Optionen")
self.transient(master.winfo_toplevel())
self.grab_set()
@@ -102,6 +116,7 @@ class OptionsDialog(tk.Toplevel):
automation = ttk.Frame(notebook, padding=16)
reminders = ttk.Frame(notebook, padding=16)
email = ttk.Frame(notebook, padding=16)
mail_templates = ttk.Frame(notebook, padding=16)
changelog = ChangelogView(notebook)
notebook.add(paths, text="Pfade")
notebook.add(appearance, text="Darstellung")
@@ -110,6 +125,7 @@ class OptionsDialog(tk.Toplevel):
notebook.add(automation, text="Hausmeister")
notebook.add(reminders, text="Mahnungen")
notebook.add(email, text="E-Mail-Versand")
notebook.add(mail_templates, text="E-Mail-Vorlagen")
notebook.add(changelog, text="Changelog")
self._build_paths(paths)
self._build_appearance(appearance)
@@ -118,6 +134,7 @@ class OptionsDialog(tk.Toplevel):
self._build_automation(automation)
self._build_reminders(reminders)
self._build_email(email)
self._build_mail_templates(mail_templates)
buttons = ttk.Frame(root)
buttons.grid(row=1, column=0, sticky="e", pady=(12, 0))
@@ -907,6 +924,7 @@ class OptionsDialog(tk.Toplevel):
standard_fee_items=self.standard_items,
)
self.repository.save_email_settings(**email_settings)
self._save_mail_templates()
except (OSError, RepositoryError) as exc:
messagebox.showerror("Optionen konnten nicht gespeichert werden", str(exc), parent=self)
return
@@ -914,6 +932,166 @@ class OptionsDialog(tk.Toplevel):
self.on_saved(store_changed)
self.destroy()
def _build_mail_templates(self, parent: ttk.Frame) -> None:
parent.columnconfigure(0, weight=1)
parent.rowconfigure(4, weight=1)
ttk.Label(
parent,
text=(
"Die Texte aller vom Programm erzeugten E-Mails liegen als bearbeitbare "
"Vorlagen im Mitglieder-Store unter templates/mail/. Platzhalter in doppelten "
"geschweiften Klammern werden beim Versand mit den Daten des Mitglieds gefüllt."
),
style="Muted.TLabel",
wraplength=820,
).grid(row=0, column=0, columnspan=2, sticky="w", pady=(0, 12))
chooser = ttk.Frame(parent)
chooser.grid(row=1, column=0, columnspan=2, sticky="ew")
chooser.columnconfigure(1, weight=1)
ttk.Label(chooser, text="Vorlage").grid(row=0, column=0, sticky="w", padx=(0, 12))
selector = ttk.Combobox(
chooser,
textvariable=self.mail_template_var,
values=[spec.label for spec in MAIL_TEMPLATES],
state="readonly",
width=32,
)
selector.grid(row=0, column=1, sticky="w")
selector.bind("<<ComboboxSelected>>", lambda _event: self._select_mail_template())
ttk.Button(
chooser, text="Standardtext wiederherstellen", command=self._reset_mail_template
).grid(row=0, column=2, sticky="e", padx=(12, 0))
ttk.Label(
parent, textvariable=self.mail_template_hint_var, style="Muted.TLabel", wraplength=820
).grid(row=2, column=0, columnspan=2, sticky="w", pady=(6, 12))
subject = ttk.Frame(parent)
subject.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(0, 8))
subject.columnconfigure(1, weight=1)
ttk.Label(subject, text="Betreff").grid(row=0, column=0, sticky="w", padx=(0, 12))
ttk.Entry(subject, textvariable=self.mail_template_subject_var).grid(
row=0, column=1, sticky="ew"
)
editor = ttk.Frame(parent)
editor.grid(row=4, column=0, sticky="nsew")
editor.columnconfigure(0, weight=1)
editor.rowconfigure(0, weight=1)
self.mail_template_body = tk.Text(editor, wrap="word", height=16, width=70, undo=True)
self.mail_template_body.grid(row=0, column=0, sticky="nsew")
body_scroll = ttk.Scrollbar(editor, orient="vertical", command=self.mail_template_body.yview)
body_scroll.grid(row=0, column=1, sticky="ns")
self.mail_template_body.configure(yscrollcommand=body_scroll.set)
placeholders = ttk.Frame(parent)
placeholders.grid(row=4, column=1, sticky="nsew", padx=(12, 0))
placeholders.columnconfigure(0, weight=1)
placeholders.rowconfigure(1, weight=1)
ttk.Label(placeholders, text="Platzhalter (Doppelklick fügt ein)").grid(
row=0, column=0, columnspan=2, sticky="w", pady=(0, 4)
)
self.mail_template_placeholders = ttk.Treeview(
placeholders, columns=("name", "help"), show="headings", selectmode="browse", height=14
)
for key, title, width in (("name", "Platzhalter", 210), ("help", "Bedeutung", 230)):
self.mail_template_placeholders.heading(key, text=title)
self.mail_template_placeholders.column(key, width=width, anchor="w", stretch=key == "help")
self.mail_template_placeholders.grid(row=1, column=0, sticky="nsew")
placeholder_scroll = ttk.Scrollbar(
placeholders, orient="vertical", command=self.mail_template_placeholders.yview
)
placeholder_scroll.grid(row=1, column=1, sticky="ns")
self.mail_template_placeholders.configure(yscrollcommand=placeholder_scroll.set)
self.mail_template_placeholders.bind(
"<Double-1>", lambda _event: self._insert_mail_placeholder()
)
self.mail_template_placeholders.bind(
"<Return>", lambda _event: self._insert_mail_placeholder()
)
self._load_mail_template(self.mail_template_key)
def _load_mail_template(self, key: str) -> None:
if key not in self.mail_template_edits:
try:
template = self.repository.get_mail_template(key)
except (OSError, RepositoryError) as exc:
messagebox.showerror(
"Vorlage konnte nicht geladen werden", str(exc), parent=self
)
return
self.mail_template_edits[key] = (template.subject, template.body)
self.mail_template_original[key] = (template.subject, template.body)
subject, body = self.mail_template_edits[key]
self.mail_template_key = key
spec = template_spec(key)
self.mail_template_var.set(spec.label)
self.mail_template_hint_var.set(f"{spec.description} (Datei: {spec.filename})")
self.mail_template_subject_var.set(subject)
self.mail_template_body.delete("1.0", "end")
self.mail_template_body.insert("1.0", body)
self.mail_template_placeholders.delete(*self.mail_template_placeholders.get_children())
for name, description in spec.placeholders:
self.mail_template_placeholders.insert(
"", "end", values=(f"{{{{{name}}}}}", description)
)
for name, description, items in spec.blocks:
self.mail_template_placeholders.insert(
"", "end", values=(f"{{{{#{name}}}}}{{{{/{name}}}}}", description)
)
for item_name, item_description in items:
self.mail_template_placeholders.insert(
"", "end", values=(f" {{{{{item_name}}}}}", item_description)
)
def _capture_mail_template(self) -> None:
if not hasattr(self, "mail_template_body"):
return
self.mail_template_edits[self.mail_template_key] = (
self.mail_template_subject_var.get(),
self.mail_template_body.get("1.0", "end-1c"),
)
def _select_mail_template(self) -> None:
self._capture_mail_template()
label = self.mail_template_var.get()
key = next((spec.key for spec in MAIL_TEMPLATES if spec.label == label), None)
if key:
self._load_mail_template(key)
def _reset_mail_template(self) -> None:
key = self.mail_template_key
spec = template_spec(key)
if not messagebox.askyesno(
"Standardtext wiederherstellen",
f"Soll die Vorlage „{spec.label}“ auf den mitgelieferten Standardtext "
"zurückgesetzt werden? Deine Änderungen an dieser Vorlage gehen dabei verloren.",
parent=self,
):
return
try:
template = default_mail_template(key)
except MailTemplateError as exc:
messagebox.showerror("Standardtext nicht verfügbar", str(exc), parent=self)
return
self.mail_template_edits[key] = (template.subject, template.body)
self._load_mail_template(key)
def _insert_mail_placeholder(self) -> None:
selected = self.mail_template_placeholders.selection()
if not selected:
return
value = str(self.mail_template_placeholders.item(selected[0], "values")[0]).strip()
self.mail_template_body.insert("insert", value)
self.mail_template_body.focus_set()
def _save_mail_templates(self) -> None:
self._capture_mail_template()
for key, (subject, body) in self.mail_template_edits.items():
if self.mail_template_original.get(key) == (subject, body):
continue
self.repository.save_mail_template(key, subject=subject, body=body)
def _center_on_parent(self) -> None:
self.update_idletasks()
parent = self.master.winfo_toplevel()