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
+94
View File
@@ -0,0 +1,94 @@
from __future__ import annotations
from datetime import datetime
from email.message import EmailMessage
from email.policy import SMTP
from email.utils import format_datetime, make_msgid
from ccma.domain.mail_templates import MailTemplate, MailTemplateError, render_mail_template
from ccma.domain.models import Member
from ccma.domain.placeholders import base_placeholder_values
from ccma.storage.repository import MemberRepository, RepositoryError
def template_values(
member: Member,
organization: dict | None,
*,
signature: str,
created_at: datetime | None = None,
) -> dict[str, str]:
values = base_placeholder_values(member, organization, created_at=created_at)
# A mail always opens with a salutation, so an empty first name would read as
# "Hallo ," -- every other placeholder may legitimately render empty.
values["member.first_name"] = member.first_name.strip() or "Mitglied"
values["signature"] = signature.strip()
return values
def payment_instructions(
member: Member,
organization: dict | None,
*,
due_date: str,
reference: str,
) -> str:
"""Ready-made payment paragraph: members with an active mandate are told the
money is collected, everyone else gets the club's bank details."""
organization = organization if isinstance(organization, dict) else {}
if member.mandate_active:
mandate = member.mandate_reference.strip()
mandate_hint = f" (Mandatsreferenz {mandate})" if mandate else ""
return (
f"Du hast uns ein SEPA-Lastschriftmandat erteilt{mandate_hint} wir ziehen den "
"Betrag fristgerecht von deinem Konto ein. Du musst also nichts weiter tun; "
"vor dem Einzug informieren wir dich rechtzeitig per E-Mail."
)
deadline = f" bis zum {due_date}" if due_date else ""
lines = [f"Bitte überweise den Betrag{deadline} auf unser Vereinskonto:", ""]
lines.append(f"IBAN: {str(organization.get('iban', '')).strip()}")
bic = str(organization.get("bic", "")).strip()
if bic:
lines.append(f"BIC: {bic}")
if reference.strip():
lines.append(f"Verwendungszweck: {reference.strip()}")
return "\n".join(lines)
def render_template(
repository: MemberRepository,
key: str,
values: dict[str, str],
repeats: dict[str, list[dict[str, str]]] | None = None,
) -> MailTemplate:
try:
return render_mail_template(repository.get_mail_template(key), values, repeats)
except MailTemplateError as exc:
raise RepositoryError(str(exc)) from exc
def compose_mail(
*,
recipient: str,
subject: str,
body: str,
sender_name: str,
sender_email: str,
created_at: datetime | None = None,
) -> bytes:
if not recipient.strip():
raise RepositoryError("Für das Mitglied ist keine E-Mail-Adresse hinterlegt.")
if not sender_email.strip() or "@" not in sender_email:
raise RepositoryError("Für den Versand ist eine gültige Absenderadresse erforderlich.")
timestamp = created_at or datetime.now().astimezone()
message = EmailMessage(policy=SMTP)
message["Message-ID"] = make_msgid(domain=sender_email.rsplit("@", 1)[-1])
message["Date"] = format_datetime(timestamp)
message["From"] = f"{sender_name.strip()} <{sender_email.strip()}>"
message["To"] = recipient.strip()
message["Subject"] = subject.strip()
message["X-Mozilla-Draft-Info"] = (
"internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0; attachmentreminder=0"
)
message.set_content(body.strip() + "\n", charset="utf-8")
return message.as_bytes()