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
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user