Files
CCMA/src/ccma/services/mail_composition.py
T
Marcel PeterkauandClaude Opus 5 d6cd58a788 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>
2026-08-28 20:49:08 +02:00

95 lines
3.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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()