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
+24 -45
View File
@@ -5,11 +5,10 @@ import re
from contextlib import nullcontext
from dataclasses import dataclass
from datetime import date, datetime
from email.message import EmailMessage
from email.policy import SMTP
from email.utils import format_datetime, make_msgid
from pathlib import Path
from ccma.domain.models import Member
from ccma.services.mail_composition import compose_mail, render_template, template_values
from ccma.services.mail_delivery import (
append_message,
ensure_imap_folder,
@@ -47,55 +46,35 @@ def _available_path(directory: Path, filename: str) -> Path:
def debit_mail_bytes(
repository: MemberRepository,
*,
recipient: str,
first_name: str,
member: Member,
debit: DirectDebit,
collection_date: date,
creditor_id: str,
organization: dict,
sender_name: str,
sender_email: str,
signature: 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 die Info-Mails 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"] = f"SEPA-Lastschrift C3MA {collection_date.strftime('%d.%m.%Y')}"
message["X-Mozilla-Draft-Info"] = (
"internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0; attachmentreminder=0"
values = template_values(member, organization, signature=signature, created_at=created_at)
values.update(
{
"debit.amount": f"{debit.amount:.2f}",
"debit.collection_date": collection_date.strftime("%d.%m.%Y"),
"debit.purpose": debit.purpose,
"debit.mandate_reference": debit.mandate_reference,
}
)
greeting = first_name.strip() or "Mitglied"
message.set_content(
"\n".join(
(
f"Hallo {greeting},",
"",
"hiermit informieren wir dich, dass wir den folgenden Betrag per "
"SEPA-Lastschrift einziehen werden:",
"",
f"Betrag: {debit.amount:.2f} Euro",
f"Einzugsdatum: {collection_date.strftime('%d.%m.%Y')}",
f"Verwendungszweck: {debit.purpose}",
f"Mandatsreferenz: {debit.mandate_reference}",
f"Gläubiger-Identifikationsnummer: {creditor_id}",
"",
"Sollten die Angaben nicht stimmen, melde dich bitte zeitnah bei uns.",
"",
"Viele Grüße",
signature.strip(),
)
),
charset="utf-8",
rendered = render_template(repository, "sepa", values)
return compose_mail(
recipient=member.email,
subject=rendered.subject,
body=rendered.body,
sender_name=sender_name,
sender_email=sender_email,
created_at=created_at,
)
return message.as_bytes()
def generate_debit_mails(
@@ -149,11 +128,11 @@ def generate_debit_mails(
warnings.append(f"{member.member_number or member.display_name}: E-Mail-Adresse fehlt.")
continue
content = debit_mail_bytes(
recipient=member.email,
first_name=member.first_name,
repository,
member=member,
debit=debit,
collection_date=collection_date,
creditor_id=creditor_id,
organization=organization,
sender_name=sender_name,
sender_email=sender_email,
signature=signature,