Files
CCMA/src/ccma/services/mail_composition.py
T
Marcel PeterkauandClaude Opus 5 c46662561e Reject unbalanced repeat markers in mail templates
validate_mail_template() only looked at placeholder names, and "{{#claims}}" is
not a placeholder -- so an unclosed block or a stray "{{/claims}}" passed the
check and rendered as itself: the member would read the marker in their mail.

Repeat markers are now checked structurally: every one names a block the template
actually has, openers and closers pair up in order, blocks do not nest (the
renderer does not support it either), and the subject takes no markers at all.
Each case explains what is wrong and what is missing.

The same check runs before sending, not just before saving: the templates are
plain files in the store and can be edited outside CCMA, where refusing to send
beats mailing a marker. Saving several edited templates now validates all of
them before writing the first, so a mistake in one no longer leaves the others
half-saved.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-28 21:19:35 +02:00

108 lines
4.0 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,
template_spec,
validate_block_markers,
)
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:
template = repository.get_mail_template(key)
try:
# The templates are plain files in the store and may have been edited outside
# CCMA, so the structural check runs again here: refusing to send beats
# sending a mail with "{{#claims}}" in its text.
validate_block_markers(key, template.subject, template.body)
return render_mail_template(template, values, repeats)
except MailTemplateError as exc:
raise RepositoryError(
f"Die E-Mail-Vorlage „{template_spec(key).label}“ ist fehlerhaft: {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()