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
@@ -38,7 +38,8 @@
|
||||
"Eine überfällige Lastschrift, für die bereits eine Rücklastschrift-Mahnung versandt wurde und deren Zahlungsfrist noch läuft, wird vom Hausmeister nicht mehr weiterhin als überfällig gemeldet, sondern als niedrig priorisierter Hinweis, dass die Klärung noch läuft.",
|
||||
"Im SEPA-Export lassen sich einzelne Lastschriften per Mehrfachauswahl von der CSV-/XML-/Info-Mail-Erstellung ausschließen (z. B. während eine Rücklastschrift geklärt wird), statt immer alle offenen Lastschriften einziehen zu müssen; standardmäßig sind alle ausgewählt.",
|
||||
"Mahnungs- und SEPA-Info-Mails müssen nicht mehr zwingend als lokale Datei gespeichert und von Hand in Thunderbird importiert werden: unter Optionen → E-Mail-Versand kann ein Postfach mit SMTP- und IMAP-Zugangsdaten hinterlegt werden, wahlweise mit direktem Versand, Ablage als Entwurf im Postfach oder einer Rückfrage bei jeder Erzeugung; die bisherige lokale Ablage bleibt als Standard erhalten.",
|
||||
"Direkt versandte Mahnungen und SEPA-Info-Mails können zusätzlich automatisch in einen Gesendet-Ordner des Postfachs abgelegt werden. Für den Entwürfe- und den Gesendet-Ordner lässt sich die tatsächliche Ordnerliste live vom Server abrufen und per Dropdown auswählen; ein noch nicht vorhandener, frei eingegebener Ordner wird bei Bedarf automatisch angelegt."
|
||||
"Direkt versandte Mahnungen und SEPA-Info-Mails können zusätzlich automatisch in einen Gesendet-Ordner des Postfachs abgelegt werden. Für den Entwürfe- und den Gesendet-Ordner lässt sich die tatsächliche Ordnerliste live vom Server abrufen und per Dropdown auswählen; ein noch nicht vorhandener, frei eingegebener Ordner wird bei Bedarf automatisch angelegt.",
|
||||
"Die Texte aller erzeugten E-Mails (Willkommen, Mahnung, SEPA-Vorabinformation) stecken jetzt in bearbeitbaren Vorlagen: Sie werden als Standard mitgeliefert, landen beim ersten Start im Mitglieder-Store unter templates/mail/ und können unter Optionen → E-Mail-Vorlagen mit Betreff, Text und Platzhaltern angepasst oder auf den Standardtext zurückgesetzt werden."
|
||||
]
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
Betreff: {{reminder.name}} – {{claim.title}}
|
||||
|
||||
Hallo {{member.first_name}},
|
||||
|
||||
zu der Forderung „{{claim.title}}“ ist noch ein Betrag offen.
|
||||
|
||||
Offener Betrag: {{claim.balance}} Euro
|
||||
Ursprünglich fällig: {{claim.due_date}}
|
||||
Neue Zahlungsfrist: {{reminder.payment_deadline}}
|
||||
{{reminder.items}}
|
||||
{{reminder.hint}}
|
||||
|
||||
Bitte überweise den offenen Betrag unter Angabe deiner Mitgliedsnummer an:
|
||||
IBAN: {{organization.iban}}
|
||||
BIC: {{organization.bic}}
|
||||
Verwendungszweck: {{payment.reference}}
|
||||
|
||||
Sollte sich deine Zahlung mit dieser Nachricht überschnitten haben, betrachte sie bitte als gegenstandslos.
|
||||
|
||||
Viele Grüße
|
||||
{{signature}}
|
||||
@@ -0,0 +1,16 @@
|
||||
Betreff: SEPA-Lastschrift {{organization.name}} – {{debit.collection_date}}
|
||||
|
||||
Hallo {{member.first_name}},
|
||||
|
||||
hiermit informieren wir dich, dass wir den folgenden Betrag per SEPA-Lastschrift einziehen werden:
|
||||
|
||||
Betrag: {{debit.amount}} Euro
|
||||
Einzugsdatum: {{debit.collection_date}}
|
||||
Verwendungszweck: {{debit.purpose}}
|
||||
Mandatsreferenz: {{debit.mandate_reference}}
|
||||
Gläubiger-Identifikationsnummer: {{organization.creditor_id}}
|
||||
|
||||
Sollten die Angaben nicht stimmen, melde dich bitte zeitnah bei uns.
|
||||
|
||||
Viele Grüße
|
||||
{{signature}}
|
||||
@@ -0,0 +1,25 @@
|
||||
Betreff: Willkommen im Club, {{member.first_name}} – deine Mitgliedschaft und der erste Beitrag
|
||||
|
||||
Hallo {{member.first_name}},
|
||||
|
||||
herzlich willkommen bei {{organization.name}}! Der Vorstand hat deine Aufnahme beschlossen: Ab dem {{membership.start_date}} bist du offiziell Mitglied. Wir freuen uns sehr, dich an Bord zu haben.
|
||||
|
||||
Deine Mitgliedsnummer lautet {{member.number}}. Damit ordnen wir Zahlungen und Rückfragen sofort zu – am besten schreibst du sie dir kurz auf.
|
||||
|
||||
Damit auch die Buchhaltung glücklich ist, kommt hier deine erste Rechnung. Deine Zahlweise ist „{{member.payment_frequency}}“, offen sind aktuell:
|
||||
|
||||
{{claims.list}}
|
||||
|
||||
Gesamtbetrag: {{claims.total}} Euro
|
||||
Zahlbar bis: {{claims.first_due_date}}
|
||||
|
||||
{{payment.instructions}}
|
||||
|
||||
Und jetzt zum wichtigen Teil: Der Club lebt von den Leuten, die mitmachen. Komm zu den Clubabenden vorbei, bring deine Projekte mit, frag Löcher in Bäuche und teile, was du weißt. Es gibt keine dummen Fragen, nur ungelötete Ideen.
|
||||
|
||||
Wenn etwas unklar ist, antworte einfach auf diese Mail oder schreib uns an {{organization.email}} – wir helfen gerne weiter.
|
||||
|
||||
Viel Spaß, viel Neugier und happy hacking!
|
||||
|
||||
Viele Grüße
|
||||
{{signature}}
|
||||
@@ -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", "")),
|
||||
}
|
||||
@@ -24,7 +24,8 @@ from ccma.domain.contributions import (
|
||||
reminder_items_total,
|
||||
)
|
||||
from ccma.domain.dates import format_date_for_display
|
||||
from ccma.domain.models import MEMBERSHIP_STATUS_LABELS, Member
|
||||
from ccma.domain.models import Member
|
||||
from ccma.domain.placeholders import base_placeholder_values
|
||||
from ccma.storage.repository import MemberRepository
|
||||
|
||||
SUPPORTED_TEMPLATE_SUFFIXES = {".fodt", ".odt", ".ott"}
|
||||
@@ -182,65 +183,14 @@ def _template_values(
|
||||
reminder: dict | None = None,
|
||||
organization: dict | None = None,
|
||||
) -> tuple[dict[str, str], dict[str, list[dict[str, str]]]]:
|
||||
organization = organization or {}
|
||||
created_at = datetime.now().astimezone()
|
||||
created_date = format_date_for_display(created_at.date().isoformat())
|
||||
created_timestamp = created_at.strftime("%d.%m.%Y %H:%M")
|
||||
organization_address = " ".join(
|
||||
part
|
||||
for part in (
|
||||
str(organization.get("street", "")),
|
||||
str(organization.get("postal_code", "")),
|
||||
str(organization.get("city", "")),
|
||||
)
|
||||
if part
|
||||
values = base_placeholder_values(member, organization, created_at=created_at)
|
||||
values.update(
|
||||
{
|
||||
"document.created_date": values["current_date"],
|
||||
"document.created_at": values["current_datetime"],
|
||||
}
|
||||
)
|
||||
values = {
|
||||
"document.created_date": created_date,
|
||||
"document.created_at": created_timestamp,
|
||||
"current_date": created_date,
|
||||
"current_datetime": created_timestamp,
|
||||
"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.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", "")),
|
||||
}
|
||||
repeats: dict[str, list[dict[str, str]]] = {}
|
||||
if claim is not None and data is not None:
|
||||
claim_id = str(claim.get("claim_id", ""))
|
||||
|
||||
@@ -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()
|
||||
@@ -6,13 +6,17 @@ import re
|
||||
from contextlib import nullcontext
|
||||
from dataclasses import dataclass
|
||||
from datetime import 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.contributions import claim_balance
|
||||
from ccma.domain.contributions import claim_balance, claim_total, money_text, reminder_items_total
|
||||
from ccma.domain.dates import format_date_for_display
|
||||
from ccma.domain.models import Member
|
||||
from ccma.services.mail_composition import (
|
||||
compose_mail,
|
||||
payment_instructions,
|
||||
render_template,
|
||||
template_values,
|
||||
)
|
||||
from ccma.services.mail_delivery import (
|
||||
append_message,
|
||||
ensure_imap_folder,
|
||||
@@ -49,9 +53,9 @@ def _available_path(directory: Path, filename: str) -> Path:
|
||||
|
||||
|
||||
def reminder_mail_bytes(
|
||||
repository: MemberRepository,
|
||||
*,
|
||||
recipient: str,
|
||||
first_name: str,
|
||||
member: Member,
|
||||
reminder: dict,
|
||||
claim: dict,
|
||||
balance: str,
|
||||
@@ -61,62 +65,64 @@ def reminder_mail_bytes(
|
||||
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 Mahnung 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"{reminder.get('name', 'Zahlungserinnerung')} – {claim.get('title', '')}"
|
||||
message["X-Mozilla-Draft-Info"] = (
|
||||
"internal/draft; vcard=0; receipt=0; DSN=0; uuencode=0; attachmentreminder=0"
|
||||
)
|
||||
iban = str(organization.get("iban", "")).strip()
|
||||
bic = str(organization.get("bic", "")).strip()
|
||||
payment_details = f"IBAN: {iban}" + (f"\nBIC: {bic}" if bic else "")
|
||||
values = template_values(member, organization, signature=signature, created_at=created_at)
|
||||
detail = str(reminder.get("detail", "")).strip()
|
||||
fee_items = reminder.get("items") or []
|
||||
fee_lines = (
|
||||
"\n".join(
|
||||
f"{item.get('description', 'Gebühr')}: {item.get('amount', '0.00')} Euro"
|
||||
for item in fee_items
|
||||
due_date = format_date_for_display(str(claim.get("due_date", "")))
|
||||
payment_deadline = format_date_for_display(str(reminder.get("payment_deadline", "")))
|
||||
reference = " – ".join(
|
||||
part
|
||||
for part in (
|
||||
f"Mitgliedsnummer {member.member_number}" if member.member_number else "",
|
||||
str(claim.get("title", "")).strip(),
|
||||
)
|
||||
if fee_items
|
||||
else "Mahngebühr: 0.00 Euro"
|
||||
if part
|
||||
)
|
||||
message.set_content(
|
||||
"\n".join(
|
||||
(
|
||||
f"Hallo {first_name.strip() or 'Mitglied'},",
|
||||
"",
|
||||
f"zu der Forderung „{claim.get('title', 'Offene Forderung')}“ "
|
||||
"ist noch ein Betrag offen.",
|
||||
"",
|
||||
f"Offener Betrag: {balance} Euro",
|
||||
f"Ursprünglich fällig: "
|
||||
f"{format_date_for_display(str(claim.get('due_date', '')))}",
|
||||
f"Neue Zahlungsfrist: "
|
||||
f"{format_date_for_display(str(reminder.get('payment_deadline', '')))}",
|
||||
fee_lines,
|
||||
f"Hinweis: {detail}" if detail else "",
|
||||
"",
|
||||
"Bitte überweise den offenen Betrag unter Angabe deiner Mitgliedsnummer an:",
|
||||
payment_details,
|
||||
"",
|
||||
"Sollte sich deine Zahlung mit dieser Nachricht überschnitten haben, "
|
||||
"betrachte sie bitte als gegenstandslos.",
|
||||
"",
|
||||
"Viele Grüße",
|
||||
signature.strip(),
|
||||
)
|
||||
),
|
||||
charset="utf-8",
|
||||
values.update(
|
||||
{
|
||||
"claim.title": str(claim.get("title", "")),
|
||||
"claim.description": str(claim.get("description", "") or claim.get("title", "")),
|
||||
"claim.due_date": due_date,
|
||||
"claim.amount": money_text(claim_total(claim)),
|
||||
"claim.balance": balance,
|
||||
"reminder.name": str(reminder.get("name", "Zahlungserinnerung")),
|
||||
"reminder.level": str(reminder.get("level", "")),
|
||||
"reminder.payment_deadline": payment_deadline,
|
||||
"reminder.detail": detail,
|
||||
"reminder.hint": f"Hinweis: {detail}" if detail else "",
|
||||
"reminder.items": "\n".join(
|
||||
f"{item.get('description', 'Gebühr')}: {item.get('amount', '0.00')} Euro"
|
||||
for item in fee_items
|
||||
),
|
||||
"reminder.fee_total": money_text(reminder_items_total(reminder)),
|
||||
"payment.reference": reference,
|
||||
"payment.instructions": payment_instructions(
|
||||
member, organization, due_date=payment_deadline, reference=reference
|
||||
),
|
||||
}
|
||||
)
|
||||
rendered = render_template(
|
||||
repository,
|
||||
"reminder",
|
||||
values,
|
||||
{
|
||||
"reminder.items": [
|
||||
{
|
||||
"item.description": str(item.get("description", "")),
|
||||
"item.amount": str(item.get("amount", "0.00")),
|
||||
}
|
||||
for item in fee_items
|
||||
]
|
||||
},
|
||||
)
|
||||
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_and_send_reminder_mail(
|
||||
@@ -155,8 +161,8 @@ def generate_and_send_reminder_mail(
|
||||
updated_data, updated_claim = repository.get_claim(member_id, claim_id)
|
||||
organization = repository.get_configuration().get("organization") or {}
|
||||
content = reminder_mail_bytes(
|
||||
recipient=member.email,
|
||||
first_name=member.first_name,
|
||||
repository,
|
||||
member=member,
|
||||
reminder=sent_reminder,
|
||||
claim=updated_claim,
|
||||
balance=f"{claim_balance(updated_data, updated_claim):.2f}",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -63,6 +63,20 @@ def write_json_atomic(path: Path, data: Any) -> None:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def write_text_atomic(path: Path, content: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{path.name}.", suffix=".tmp", dir=path.parent)
|
||||
temporary = Path(temporary_name)
|
||||
try:
|
||||
with os.fdopen(descriptor, "w", encoding="utf-8", newline="\n") as handle:
|
||||
handle.write(content)
|
||||
handle.flush()
|
||||
os.fsync(handle.fileno())
|
||||
os.replace(temporary, path)
|
||||
finally:
|
||||
temporary.unlink(missing_ok=True)
|
||||
|
||||
|
||||
def read_json(path: Path) -> Any:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
return json.load(handle)
|
||||
|
||||
@@ -35,6 +35,16 @@ from ccma.domain.dates import (
|
||||
parse_month_input,
|
||||
validate_member_dates,
|
||||
)
|
||||
from ccma.domain.mail_templates import (
|
||||
MAIL_TEMPLATES,
|
||||
MailTemplate,
|
||||
MailTemplateError,
|
||||
default_mail_template,
|
||||
parse_mail_template,
|
||||
serialize_mail_template,
|
||||
template_spec,
|
||||
validate_mail_template,
|
||||
)
|
||||
from ccma.domain.models import (
|
||||
ASSET_CUSTODY_TYPE_LABELS,
|
||||
ASSET_OWNER_TYPE_LABELS,
|
||||
@@ -46,7 +56,12 @@ from ccma.domain.models import (
|
||||
Event,
|
||||
Member,
|
||||
)
|
||||
from ccma.storage.atomic import json_content_hash_matches, read_json, write_json_atomic
|
||||
from ccma.storage.atomic import (
|
||||
json_content_hash_matches,
|
||||
read_json,
|
||||
write_json_atomic,
|
||||
write_text_atomic,
|
||||
)
|
||||
|
||||
|
||||
class RepositoryError(RuntimeError):
|
||||
@@ -183,6 +198,18 @@ class MemberRepository:
|
||||
destination = templates_root / destination_name
|
||||
if source.is_file() and not destination.exists():
|
||||
shutil.copyfile(source, destination)
|
||||
mail_templates_root = self.mail_templates_root
|
||||
mail_templates_root.mkdir(parents=True, exist_ok=True)
|
||||
for spec in MAIL_TEMPLATES:
|
||||
destination = mail_templates_root / spec.filename
|
||||
if not destination.exists():
|
||||
try:
|
||||
template = default_mail_template(spec.key)
|
||||
write_text_atomic(
|
||||
destination, serialize_mail_template(template.subject, template.body)
|
||||
)
|
||||
except (MailTemplateError, OSError):
|
||||
continue
|
||||
config_path = self.root / "repository.json"
|
||||
if not config_path.exists():
|
||||
write_json_atomic(config_path, DEFAULT_CONFIGURATION)
|
||||
@@ -2722,6 +2749,42 @@ class MemberRepository:
|
||||
}
|
||||
write_json_atomic(self.root / "repository.json", config)
|
||||
|
||||
@property
|
||||
def mail_templates_root(self) -> Path:
|
||||
return self.root / "templates" / "mail"
|
||||
|
||||
def get_mail_template(self, key: str) -> MailTemplate:
|
||||
"""Reads the store's copy of a mail template. Stores created before mail
|
||||
templates existed (or with a deleted file) fall back to the shipped default
|
||||
and get the file written back, so the board always has an editable copy."""
|
||||
spec = template_spec(key)
|
||||
path = self.mail_templates_root / spec.filename
|
||||
try:
|
||||
return parse_mail_template(path.read_text(encoding="utf-8"))
|
||||
except OSError:
|
||||
template = default_mail_template(key)
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
write_text_atomic(path, serialize_mail_template(template.subject, template.body))
|
||||
except OSError:
|
||||
pass
|
||||
return template
|
||||
|
||||
def save_mail_template(self, key: str, *, subject: str, body: str) -> None:
|
||||
spec = template_spec(key)
|
||||
try:
|
||||
validate_mail_template(key, subject, body)
|
||||
except MailTemplateError as exc:
|
||||
raise RepositoryError(str(exc)) from exc
|
||||
path = self.mail_templates_root / spec.filename
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
write_text_atomic(path, serialize_mail_template(subject, body))
|
||||
|
||||
def reset_mail_template(self, key: str) -> MailTemplate:
|
||||
template = default_mail_template(key)
|
||||
self.save_mail_template(key, subject=template.subject, body=template.body)
|
||||
return template
|
||||
|
||||
def save_organization(self, values: dict[str, str]) -> None:
|
||||
organization = {key: str(value).strip() for key, value in values.items()}
|
||||
organization["iban"] = normalize_iban(organization.get("iban", ""))
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user