mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 03:20:49 +02:00
Merge branch 'dev' into feature/read-only-store
dev gained the editable mail templates, which live in the store like everything else -- so they follow the same read-only rules: the options dialog skips saving them, save_mail_template() reports the store instead of a raw PermissionError, and a store that cannot keep its own copy simply renders from the shipped default. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -183,6 +183,30 @@ marker may share the last cell. CCMA removes both markers and clones the whole
|
|||||||
row, including its formatting, once per item. With no items, the template row
|
row, including its formatting, once per item. With no items, the template row
|
||||||
is removed. A loop that is not closed in the same row is rejected.
|
is removed. A loop that is not closed in the same row is rejected.
|
||||||
|
|
||||||
|
## Mail templates
|
||||||
|
|
||||||
|
Every e-mail CCMA sends -- the welcome mail with its first invoice, dunning mails
|
||||||
|
and SEPA pre-notifications -- is rendered from a plain text template. The shipped
|
||||||
|
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; a deleted one is restored from the shipped default.
|
||||||
|
|
||||||
|
A template holds the subject in its first line and the message body after a blank
|
||||||
|
line:
|
||||||
|
|
||||||
|
```
|
||||||
|
Betreff: {{reminder.name}} – {{claim.title}}
|
||||||
|
|
||||||
|
Hallo {{member.first_name}},
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Placeholders use the same `{{ ... }}` syntax as the document templates. Which ones
|
||||||
|
are available depends on the mail; the options dialog lists them per template and
|
||||||
|
rejects unknown ones when saving. A line that contains nothing but placeholders
|
||||||
|
which render empty (an optional hint, an empty list) is dropped, and
|
||||||
|
`{{#claims}} ... {{/claims}}` repeats its content once per claim.
|
||||||
|
|
||||||
## Read-only stores
|
## Read-only stores
|
||||||
|
|
||||||
The member store may be mounted read-only -- the encrypted volume holding the
|
The member store may be mounted read-only -- the encrypted volume holding the
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ ccma = [
|
|||||||
"assets/CHANGELOG.json",
|
"assets/CHANGELOG.json",
|
||||||
"assets/splash.png",
|
"assets/splash.png",
|
||||||
"assets/templates/*",
|
"assets/templates/*",
|
||||||
|
"assets/mail_templates/*",
|
||||||
"assets/themes/forest/**/*",
|
"assets/themes/forest/**/*",
|
||||||
"assets/themes/forest/*",
|
"assets/themes/forest/*",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -39,6 +39,8 @@
|
|||||||
"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.",
|
"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.",
|
"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.",
|
||||||
|
"Für neue Mitglieder kann direkt aus der Mitgliederakte eine Willkommens- und Erstrechnungsmail erzeugt werden: Sie begrüßt das Mitglied, nennt Mitgliedsnummer und Beginn der Mitgliedschaft und stellt die noch offenen Forderungen (Aufnahmegebühr und erster Beitrag) mit Zahlungshinweis zusammen; einzelne Forderungen lassen sich abwählen und die fertige Mail vorab ansehen.",
|
||||||
"CCMA startet jetzt auch, wenn der Mitglieder-Store schreibgeschützt eingebunden ist (z. B. ein nur lesend gemountetes Crypt-Laufwerk): Die Daten lassen sich wie gewohnt ansehen, der Hausmeister wird übersprungen und alle schreibenden Aktionen werden mit einer verständlichen Meldung abgelehnt statt mit einem Systemfehler. Ein dauerhaft sichtbarer Warnhinweis über den Tabs sowie Markierungen in Fenstertitel und Statusleiste weisen auf den Nur-Lese-Betrieb hin; wird der Store wieder mit Schreibrechten eingebunden, genügt „Erneut prüfen“ ohne Neustart."
|
"CCMA startet jetzt auch, wenn der Mitglieder-Store schreibgeschützt eingebunden ist (z. B. ein nur lesend gemountetes Crypt-Laufwerk): Die Daten lassen sich wie gewohnt ansehen, der Hausmeister wird übersprungen und alle schreibenden Aktionen werden mit einer verständlichen Meldung abgelehnt statt mit einem Systemfehler. Ein dauerhaft sichtbarer Warnhinweis über den Tabs sowie Markierungen in Fenstertitel und Statusleiste weisen auf den Nur-Lese-Betrieb hin; wird der Store wieder mit Schreibrechten eingebunden, genügt „Erneut prüfen“ ohne Neustart."
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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,387 @@
|
|||||||
|
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.
|
||||||
|
# Matches a single repeat marker, opening or closing. The placeholder pattern
|
||||||
|
# deliberately does not, so unbalanced markers need a check of their own -- they
|
||||||
|
# would otherwise survive validation and end up verbatim in a sent mail.
|
||||||
|
MARKER_PATTERN = re.compile(r"\{\{\s*([#/])\s*([a-z][a-z0-9_.]*)\s*\}\}", re.IGNORECASE)
|
||||||
|
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 MailTemplateBlock:
|
||||||
|
name: str
|
||||||
|
description: str
|
||||||
|
# Placeholder name -> short explanation, valid inside this block only.
|
||||||
|
placeholders: tuple[tuple[str, str], ...]
|
||||||
|
# The line the inserted block starts out with. It makes the inserted snippet a
|
||||||
|
# working block that renders real content -- the board edits this line rather
|
||||||
|
# than having to write the whole construct by hand.
|
||||||
|
sample: str
|
||||||
|
|
||||||
|
@property
|
||||||
|
def snippet(self) -> str:
|
||||||
|
return f"{{{{#{self.name}}}}}\n{self.sample}\n{{{{/{self.name}}}}}\n"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class PlaceholderEntry:
|
||||||
|
"""One row of the options dialog's placeholder chooser: what it reads as, and
|
||||||
|
what double-clicking it puts into the template."""
|
||||||
|
|
||||||
|
label: str
|
||||||
|
snippet: str
|
||||||
|
description: str
|
||||||
|
indented: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
@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], ...]
|
||||||
|
blocks: tuple[MailTemplateBlock, ...] = ()
|
||||||
|
|
||||||
|
|
||||||
|
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=(
|
||||||
|
MailTemplateBlock(
|
||||||
|
name="claims",
|
||||||
|
description="Wiederholt sich je ausgewählter Forderung",
|
||||||
|
placeholders=CLAIM_ITEM_PLACEHOLDERS,
|
||||||
|
sample="{{claim.description}} (fällig {{claim.due_date}}): {{claim.balance}} Euro",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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=(
|
||||||
|
MailTemplateBlock(
|
||||||
|
name="reminder.items",
|
||||||
|
description="Wiederholt sich je Mahnposition",
|
||||||
|
placeholders=(
|
||||||
|
("item.description", "Bezeichnung der Position"),
|
||||||
|
("item.amount", "Betrag der Position"),
|
||||||
|
),
|
||||||
|
sample="{{item.description}}: {{item.amount}} Euro",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
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 placeholder_entries(key: str) -> list[PlaceholderEntry]:
|
||||||
|
"""Rows for the placeholder chooser. Repeat blocks contribute a complete,
|
||||||
|
ready-to-edit block -- inserting one has to leave a working template behind,
|
||||||
|
not a description of one."""
|
||||||
|
spec = template_spec(key)
|
||||||
|
entries = [
|
||||||
|
PlaceholderEntry(f"{{{{{name}}}}}", f"{{{{{name}}}}}", description)
|
||||||
|
for name, description in spec.placeholders
|
||||||
|
]
|
||||||
|
for block in spec.blocks:
|
||||||
|
entries.append(
|
||||||
|
PlaceholderEntry(
|
||||||
|
f"{{{{#{block.name}}}}} … {{{{/{block.name}}}}}", block.snippet, block.description
|
||||||
|
)
|
||||||
|
)
|
||||||
|
entries.extend(
|
||||||
|
PlaceholderEntry(f"{{{{{name}}}}}", f"{{{{{name}}}}}", description, indented=True)
|
||||||
|
for name, description in block.placeholders
|
||||||
|
)
|
||||||
|
return entries
|
||||||
|
|
||||||
|
|
||||||
|
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_block_markers(key: str, subject: str, body: str) -> None:
|
||||||
|
"""Checks that every {{#block}} is closed by its own {{/block}} and that no
|
||||||
|
stray marker is left over. An unbalanced marker is not a placeholder, so it
|
||||||
|
renders as itself -- the recipient would read "{{#claims}}" in their mail."""
|
||||||
|
spec = template_spec(key)
|
||||||
|
known = {block.name.casefold(): block.name for block in spec.blocks}
|
||||||
|
for match in MARKER_PATTERN.finditer(subject):
|
||||||
|
raise MailTemplateError(
|
||||||
|
f"Wiederholungsblöcke sind im Betreff nicht möglich: {match.group(0)}"
|
||||||
|
)
|
||||||
|
open_marker: tuple[str, str] | None = None
|
||||||
|
for match in MARKER_PATTERN.finditer(body):
|
||||||
|
kind, name = match.group(1), match.group(2)
|
||||||
|
if name.casefold() not in known:
|
||||||
|
raise MailTemplateError(
|
||||||
|
f"Unbekannter Wiederholungsblock: {match.group(0)}. Verfügbar: "
|
||||||
|
+ (", ".join(f"{{{{#{value}}}}}" for value in known.values()) or "keine")
|
||||||
|
)
|
||||||
|
if kind == "#":
|
||||||
|
if open_marker is not None:
|
||||||
|
raise MailTemplateError(
|
||||||
|
f"Wiederholungsblöcke können nicht ineinander verschachtelt werden: "
|
||||||
|
f"{{{{#{open_marker[1]}}}}} ist noch offen, als {match.group(0)} beginnt."
|
||||||
|
)
|
||||||
|
open_marker = (kind, name)
|
||||||
|
continue
|
||||||
|
if open_marker is None:
|
||||||
|
raise MailTemplateError(
|
||||||
|
f"{match.group(0)} steht ohne zugehöriges {{{{#{name}}}}} in der Vorlage."
|
||||||
|
)
|
||||||
|
if open_marker[1].casefold() != name.casefold():
|
||||||
|
raise MailTemplateError(
|
||||||
|
f"{{{{#{open_marker[1]}}}}} wird durch {match.group(0)} geschlossen. "
|
||||||
|
f"Erwartet wird {{{{/{open_marker[1]}}}}}."
|
||||||
|
)
|
||||||
|
open_marker = None
|
||||||
|
if open_marker is not None:
|
||||||
|
raise MailTemplateError(
|
||||||
|
f"{{{{#{open_marker[1]}}}}} wird nicht geschlossen. Es fehlt "
|
||||||
|
f"{{{{/{open_marker[1]}}}}}."
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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)
|
||||||
|
validate_block_markers(key, subject, body)
|
||||||
|
known = {name for name, _help in spec.placeholders}
|
||||||
|
block_names = {block.name for block in spec.blocks}
|
||||||
|
unknown: set[str] = set()
|
||||||
|
for block in spec.blocks:
|
||||||
|
block_known = known | {name for name, _help in block.placeholders}
|
||||||
|
for match in _blocks_of(body, block.name):
|
||||||
|
unknown.update(name for name in _placeholder_names(match) if name not in block_known)
|
||||||
|
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,
|
reminder_items_total,
|
||||||
)
|
)
|
||||||
from ccma.domain.dates import format_date_for_display
|
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
|
from ccma.storage.repository import MemberRepository
|
||||||
|
|
||||||
SUPPORTED_TEMPLATE_SUFFIXES = {".fodt", ".odt", ".ott"}
|
SUPPORTED_TEMPLATE_SUFFIXES = {".fodt", ".odt", ".ott"}
|
||||||
@@ -183,65 +184,14 @@ def _template_values(
|
|||||||
reminder: dict | None = None,
|
reminder: dict | None = None,
|
||||||
organization: dict | None = None,
|
organization: dict | None = None,
|
||||||
) -> tuple[dict[str, str], dict[str, list[dict[str, str]]]]:
|
) -> tuple[dict[str, str], dict[str, list[dict[str, str]]]]:
|
||||||
organization = organization or {}
|
|
||||||
created_at = datetime.now().astimezone()
|
created_at = datetime.now().astimezone()
|
||||||
created_date = format_date_for_display(created_at.date().isoformat())
|
values = base_placeholder_values(member, organization, created_at=created_at)
|
||||||
created_timestamp = created_at.strftime("%d.%m.%Y %H:%M")
|
values.update(
|
||||||
organization_address = " ".join(
|
{
|
||||||
part
|
"document.created_date": values["current_date"],
|
||||||
for part in (
|
"document.created_at": values["current_datetime"],
|
||||||
str(organization.get("street", "")),
|
}
|
||||||
str(organization.get("postal_code", "")),
|
|
||||||
str(organization.get("city", "")),
|
|
||||||
)
|
|
||||||
if part
|
|
||||||
)
|
)
|
||||||
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]]] = {}
|
repeats: dict[str, list[dict[str, str]]] = {}
|
||||||
if claim is not None and data is not None:
|
if claim is not None and data is not None:
|
||||||
claim_id = str(claim.get("claim_id", ""))
|
claim_id = str(claim.get("claim_id", ""))
|
||||||
|
|||||||
@@ -0,0 +1,107 @@
|
|||||||
|
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()
|
||||||
@@ -6,13 +6,17 @@ import re
|
|||||||
from contextlib import nullcontext
|
from contextlib import nullcontext
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import datetime
|
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 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.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 (
|
from ccma.services.mail_delivery import (
|
||||||
append_message,
|
append_message,
|
||||||
ensure_imap_folder,
|
ensure_imap_folder,
|
||||||
@@ -49,9 +53,9 @@ def _available_path(directory: Path, filename: str) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def reminder_mail_bytes(
|
def reminder_mail_bytes(
|
||||||
|
repository: MemberRepository,
|
||||||
*,
|
*,
|
||||||
recipient: str,
|
member: Member,
|
||||||
first_name: str,
|
|
||||||
reminder: dict,
|
reminder: dict,
|
||||||
claim: dict,
|
claim: dict,
|
||||||
balance: str,
|
balance: str,
|
||||||
@@ -61,62 +65,64 @@ def reminder_mail_bytes(
|
|||||||
signature: str,
|
signature: str,
|
||||||
created_at: datetime | None = None,
|
created_at: datetime | None = None,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
if not recipient.strip():
|
values = template_values(member, organization, signature=signature, created_at=created_at)
|
||||||
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 "")
|
|
||||||
detail = str(reminder.get("detail", "")).strip()
|
detail = str(reminder.get("detail", "")).strip()
|
||||||
fee_items = reminder.get("items") or []
|
fee_items = reminder.get("items") or []
|
||||||
fee_lines = (
|
due_date = format_date_for_display(str(claim.get("due_date", "")))
|
||||||
"\n".join(
|
payment_deadline = format_date_for_display(str(reminder.get("payment_deadline", "")))
|
||||||
f"{item.get('description', 'Gebühr')}: {item.get('amount', '0.00')} Euro"
|
reference = " – ".join(
|
||||||
for item in fee_items
|
part
|
||||||
|
for part in (
|
||||||
|
f"Mitgliedsnummer {member.member_number}" if member.member_number else "",
|
||||||
|
str(claim.get("title", "")).strip(),
|
||||||
)
|
)
|
||||||
if fee_items
|
if part
|
||||||
else "Mahngebühr: 0.00 Euro"
|
|
||||||
)
|
)
|
||||||
message.set_content(
|
values.update(
|
||||||
"\n".join(
|
{
|
||||||
(
|
"claim.title": str(claim.get("title", "")),
|
||||||
f"Hallo {first_name.strip() or 'Mitglied'},",
|
"claim.description": str(claim.get("description", "") or claim.get("title", "")),
|
||||||
"",
|
"claim.due_date": due_date,
|
||||||
f"zu der Forderung „{claim.get('title', 'Offene Forderung')}“ "
|
"claim.amount": money_text(claim_total(claim)),
|
||||||
"ist noch ein Betrag offen.",
|
"claim.balance": balance,
|
||||||
"",
|
"reminder.name": str(reminder.get("name", "Zahlungserinnerung")),
|
||||||
f"Offener Betrag: {balance} Euro",
|
"reminder.level": str(reminder.get("level", "")),
|
||||||
f"Ursprünglich fällig: "
|
"reminder.payment_deadline": payment_deadline,
|
||||||
f"{format_date_for_display(str(claim.get('due_date', '')))}",
|
"reminder.detail": detail,
|
||||||
f"Neue Zahlungsfrist: "
|
"reminder.hint": f"Hinweis: {detail}" if detail else "",
|
||||||
f"{format_date_for_display(str(reminder.get('payment_deadline', '')))}",
|
"reminder.items": "\n".join(
|
||||||
fee_lines,
|
f"{item.get('description', 'Gebühr')}: {item.get('amount', '0.00')} Euro"
|
||||||
f"Hinweis: {detail}" if detail else "",
|
for item in fee_items
|
||||||
"",
|
),
|
||||||
"Bitte überweise den offenen Betrag unter Angabe deiner Mitgliedsnummer an:",
|
"reminder.fee_total": money_text(reminder_items_total(reminder)),
|
||||||
payment_details,
|
"payment.reference": reference,
|
||||||
"",
|
"payment.instructions": payment_instructions(
|
||||||
"Sollte sich deine Zahlung mit dieser Nachricht überschnitten haben, "
|
member, organization, due_date=payment_deadline, reference=reference
|
||||||
"betrachte sie bitte als gegenstandslos.",
|
),
|
||||||
"",
|
}
|
||||||
"Viele Grüße",
|
)
|
||||||
signature.strip(),
|
rendered = render_template(
|
||||||
)
|
repository,
|
||||||
),
|
"reminder",
|
||||||
charset="utf-8",
|
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(
|
def generate_and_send_reminder_mail(
|
||||||
@@ -168,8 +174,8 @@ def generate_and_send_reminder_mail(
|
|||||||
updated_data, updated_claim = repository.get_claim(member_id, claim_id)
|
updated_data, updated_claim = repository.get_claim(member_id, claim_id)
|
||||||
organization = repository.get_configuration().get("organization") or {}
|
organization = repository.get_configuration().get("organization") or {}
|
||||||
content = reminder_mail_bytes(
|
content = reminder_mail_bytes(
|
||||||
recipient=member.email,
|
repository,
|
||||||
first_name=member.first_name,
|
member=member,
|
||||||
reminder=sent_reminder,
|
reminder=sent_reminder,
|
||||||
claim=updated_claim,
|
claim=updated_claim,
|
||||||
balance=f"{claim_balance(updated_data, updated_claim):.2f}",
|
balance=f"{claim_balance(updated_data, updated_claim):.2f}",
|
||||||
|
|||||||
@@ -5,11 +5,10 @@ import re
|
|||||||
from contextlib import nullcontext
|
from contextlib import nullcontext
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date, datetime
|
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 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 (
|
from ccma.services.mail_delivery import (
|
||||||
append_message,
|
append_message,
|
||||||
ensure_imap_folder,
|
ensure_imap_folder,
|
||||||
@@ -47,55 +46,35 @@ def _available_path(directory: Path, filename: str) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def debit_mail_bytes(
|
def debit_mail_bytes(
|
||||||
|
repository: MemberRepository,
|
||||||
*,
|
*,
|
||||||
recipient: str,
|
member: Member,
|
||||||
first_name: str,
|
|
||||||
debit: DirectDebit,
|
debit: DirectDebit,
|
||||||
collection_date: date,
|
collection_date: date,
|
||||||
creditor_id: str,
|
organization: dict,
|
||||||
sender_name: str,
|
sender_name: str,
|
||||||
sender_email: str,
|
sender_email: str,
|
||||||
signature: str,
|
signature: str,
|
||||||
created_at: datetime | None = None,
|
created_at: datetime | None = None,
|
||||||
) -> bytes:
|
) -> bytes:
|
||||||
if not recipient.strip():
|
values = template_values(member, organization, signature=signature, created_at=created_at)
|
||||||
raise RepositoryError("Für das Mitglied ist keine E-Mail-Adresse hinterlegt.")
|
values.update(
|
||||||
if not sender_email.strip() or "@" not in sender_email:
|
{
|
||||||
raise RepositoryError("Für die Info-Mails ist eine gültige Absenderadresse erforderlich.")
|
"debit.amount": f"{debit.amount:.2f}",
|
||||||
timestamp = created_at or datetime.now().astimezone()
|
"debit.collection_date": collection_date.strftime("%d.%m.%Y"),
|
||||||
message = EmailMessage(policy=SMTP)
|
"debit.purpose": debit.purpose,
|
||||||
message["Message-ID"] = make_msgid(domain=sender_email.rsplit("@", 1)[-1])
|
"debit.mandate_reference": debit.mandate_reference,
|
||||||
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"
|
|
||||||
)
|
)
|
||||||
greeting = first_name.strip() or "Mitglied"
|
rendered = render_template(repository, "sepa", values)
|
||||||
message.set_content(
|
return compose_mail(
|
||||||
"\n".join(
|
recipient=member.email,
|
||||||
(
|
subject=rendered.subject,
|
||||||
f"Hallo {greeting},",
|
body=rendered.body,
|
||||||
"",
|
sender_name=sender_name,
|
||||||
"hiermit informieren wir dich, dass wir den folgenden Betrag per "
|
sender_email=sender_email,
|
||||||
"SEPA-Lastschrift einziehen werden:",
|
created_at=created_at,
|
||||||
"",
|
|
||||||
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",
|
|
||||||
)
|
)
|
||||||
return message.as_bytes()
|
|
||||||
|
|
||||||
|
|
||||||
def generate_debit_mails(
|
def generate_debit_mails(
|
||||||
@@ -160,11 +139,11 @@ def generate_debit_mails(
|
|||||||
archive_failure: Exception | None = None
|
archive_failure: Exception | None = None
|
||||||
try:
|
try:
|
||||||
content = debit_mail_bytes(
|
content = debit_mail_bytes(
|
||||||
recipient=member.email,
|
repository,
|
||||||
first_name=member.first_name,
|
member=member,
|
||||||
debit=debit,
|
debit=debit,
|
||||||
collection_date=collection_date,
|
collection_date=collection_date,
|
||||||
creditor_id=creditor_id,
|
organization=organization,
|
||||||
sender_name=sender_name,
|
sender_name=sender_name,
|
||||||
sender_email=sender_email,
|
sender_email=sender_email,
|
||||||
signature=signature,
|
signature=signature,
|
||||||
|
|||||||
@@ -0,0 +1,269 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import hashlib
|
||||||
|
import os
|
||||||
|
from contextlib import nullcontext
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, datetime
|
||||||
|
from decimal import Decimal
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from ccma.domain.contributions import claim_balance, claim_status, claim_total, money_text
|
||||||
|
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,
|
||||||
|
imap_session,
|
||||||
|
send_via_smtp,
|
||||||
|
smtp_session,
|
||||||
|
)
|
||||||
|
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||||
|
|
||||||
|
OPEN_CLAIM_STATUSES = {"open", "partially_paid", "overdue"}
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class GeneratedWelcomeMail:
|
||||||
|
# None for "send"/"drafts" delivery -- see reminder_mail.GeneratedReminderMail.
|
||||||
|
export_path: Path | None
|
||||||
|
archive_path: Path
|
||||||
|
sha256: str
|
||||||
|
|
||||||
|
|
||||||
|
def open_claims(repository: MemberRepository, member_id: str) -> list[dict]:
|
||||||
|
"""The claims a welcome mail can bill: everything still owed, which for a fresh
|
||||||
|
member is the admission fee plus the first contribution claim(s)."""
|
||||||
|
data = repository.get_contributions(member_id)
|
||||||
|
selected = [
|
||||||
|
claim for claim in data.claims if claim_status(data, claim) in OPEN_CLAIM_STATUSES
|
||||||
|
]
|
||||||
|
return sorted(selected, key=lambda claim: (str(claim.get("due_date", "")), str(claim.get("title", ""))))
|
||||||
|
|
||||||
|
|
||||||
|
def membership_start(member: Member, *, today: date | None = None) -> date:
|
||||||
|
"""The date the membership (and with it the first contribution) starts. A member
|
||||||
|
accepted this month usually starts on the 1st of the next one, and that date
|
||||||
|
cannot be stored yet -- "Mitglied seit" rejects future dates."""
|
||||||
|
try:
|
||||||
|
return date.fromisoformat(str(member.membership_started_at))
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
reference = date.fromisoformat(str(member.accepted_at))
|
||||||
|
except ValueError:
|
||||||
|
reference = today or date.today()
|
||||||
|
return date(reference.year + reference.month // 12, reference.month % 12 + 1, 1)
|
||||||
|
|
||||||
|
|
||||||
|
def welcome_mail_bytes(
|
||||||
|
repository: MemberRepository,
|
||||||
|
*,
|
||||||
|
member: Member,
|
||||||
|
claims: list[dict],
|
||||||
|
balances: dict[str, Decimal],
|
||||||
|
organization: dict,
|
||||||
|
sender_name: str,
|
||||||
|
sender_email: str,
|
||||||
|
signature: str,
|
||||||
|
created_at: datetime | None = None,
|
||||||
|
) -> bytes:
|
||||||
|
values = template_values(member, organization, signature=signature, created_at=created_at)
|
||||||
|
entries = [
|
||||||
|
{
|
||||||
|
"claim.title": str(claim.get("title", "")),
|
||||||
|
"claim.description": str(claim.get("description", "") or claim.get("title", "")),
|
||||||
|
"claim.due_date": format_date_for_display(str(claim.get("due_date", ""))),
|
||||||
|
"claim.amount": money_text(claim_total(claim)),
|
||||||
|
"claim.balance": money_text(balances[str(claim.get("claim_id", ""))]),
|
||||||
|
}
|
||||||
|
for claim in claims
|
||||||
|
]
|
||||||
|
total = sum((balances[str(claim.get("claim_id", ""))] for claim in claims), Decimal("0"))
|
||||||
|
due_dates = sorted(
|
||||||
|
str(claim.get("due_date", "")) for claim in claims if str(claim.get("due_date", ""))
|
||||||
|
)
|
||||||
|
first_due_date = format_date_for_display(due_dates[0]) if due_dates else ""
|
||||||
|
reference = " – ".join(
|
||||||
|
part
|
||||||
|
for part in (
|
||||||
|
f"Mitgliedsnummer {member.member_number}" if member.member_number else "",
|
||||||
|
"Mitgliedsbeitrag",
|
||||||
|
)
|
||||||
|
if part
|
||||||
|
)
|
||||||
|
values.update(
|
||||||
|
{
|
||||||
|
"membership.start_date": format_date_for_display(
|
||||||
|
membership_start(member).isoformat()
|
||||||
|
),
|
||||||
|
"claims.list": "\n".join(
|
||||||
|
f"{entry['claim.description']} (fällig {entry['claim.due_date']}): "
|
||||||
|
f"{entry['claim.balance']} Euro"
|
||||||
|
for entry in entries
|
||||||
|
),
|
||||||
|
"claims.total": money_text(total),
|
||||||
|
"claims.count": str(len(entries)),
|
||||||
|
"claims.first_due_date": first_due_date,
|
||||||
|
"payment.reference": reference,
|
||||||
|
"payment.instructions": payment_instructions(
|
||||||
|
member, organization, due_date=first_due_date, reference=reference
|
||||||
|
),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
rendered = render_template(repository, "welcome", values, {"claims": entries})
|
||||||
|
return compose_mail(
|
||||||
|
recipient=member.email,
|
||||||
|
subject=rendered.subject,
|
||||||
|
body=rendered.body,
|
||||||
|
sender_name=sender_name,
|
||||||
|
sender_email=sender_email,
|
||||||
|
created_at=created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def generate_and_send_welcome_mail(
|
||||||
|
repository: MemberRepository,
|
||||||
|
member_id: str,
|
||||||
|
*,
|
||||||
|
claim_ids: list[str] | None = None,
|
||||||
|
delivery_mode: str,
|
||||||
|
output_path: Path | str | None = None,
|
||||||
|
sender_name: str,
|
||||||
|
sender_email: str,
|
||||||
|
signature: str,
|
||||||
|
) -> GeneratedWelcomeMail:
|
||||||
|
if delivery_mode not in {"local", "send", "drafts"}:
|
||||||
|
raise RepositoryError(f"Unbekannter Versandmodus: {delivery_mode}")
|
||||||
|
member = repository.get_member(member_id)
|
||||||
|
if not member.email.strip():
|
||||||
|
raise RepositoryError("Für das Mitglied ist keine E-Mail-Adresse hinterlegt.")
|
||||||
|
data = repository.get_contributions(member_id)
|
||||||
|
selected_ids = None if claim_ids is None else {str(value) for value in claim_ids}
|
||||||
|
claims = [
|
||||||
|
claim
|
||||||
|
for claim in open_claims(repository, member_id)
|
||||||
|
if selected_ids is None or str(claim.get("claim_id", "")) in selected_ids
|
||||||
|
]
|
||||||
|
if selected_ids is not None and len(claims) != len(selected_ids):
|
||||||
|
raise RepositoryError("Mindestens eine ausgewählte Forderung ist nicht mehr offen.")
|
||||||
|
balances = {
|
||||||
|
str(claim.get("claim_id", "")): claim_balance(data, claim) for claim in claims
|
||||||
|
}
|
||||||
|
target: Path | None = None
|
||||||
|
if delivery_mode == "local":
|
||||||
|
if not output_path:
|
||||||
|
raise RepositoryError("Kein Zielpfad für die lokale Ablage angegeben.")
|
||||||
|
target = Path(output_path)
|
||||||
|
target.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
if target.exists():
|
||||||
|
raise RepositoryError(f"Die Ausgabedatei existiert bereits: {target}")
|
||||||
|
organization = repository.get_configuration().get("organization") or {}
|
||||||
|
content = welcome_mail_bytes(
|
||||||
|
repository,
|
||||||
|
member=member,
|
||||||
|
claims=claims,
|
||||||
|
balances=balances,
|
||||||
|
organization=organization,
|
||||||
|
sender_name=sender_name,
|
||||||
|
sender_email=sender_email,
|
||||||
|
signature=signature,
|
||||||
|
)
|
||||||
|
export_path: Path | None = None
|
||||||
|
archive_path: Path | None = None
|
||||||
|
archive_temp: Path | None = None
|
||||||
|
# Same rule as the dunning/SEPA mails: once the mail has reached the SMTP server,
|
||||||
|
# the IMAP folder or the local export file, a later archiving failure must not
|
||||||
|
# hide that it went out -- the event is written either way and the archiving
|
||||||
|
# problem is reported afterwards.
|
||||||
|
delivered = False
|
||||||
|
archive_failure: Exception | None = None
|
||||||
|
try:
|
||||||
|
archive_dir = repository.members_root / member_id / "files" / "documents" / "Willkommen"
|
||||||
|
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||||
|
archive_path = _available_path(archive_dir, f"{date.today().isoformat()}-Willkommen.eml")
|
||||||
|
archive_temp = archive_path.with_name(f".{archive_path.name}.tmp")
|
||||||
|
archive_temp.write_bytes(content)
|
||||||
|
if delivery_mode == "local":
|
||||||
|
export_temp = target.with_name(f".{target.name}.tmp")
|
||||||
|
try:
|
||||||
|
export_temp.write_bytes(content)
|
||||||
|
os.replace(export_temp, target)
|
||||||
|
finally:
|
||||||
|
export_temp.unlink(missing_ok=True)
|
||||||
|
export_path = target
|
||||||
|
delivered = True
|
||||||
|
elif delivery_mode == "send":
|
||||||
|
settings = repository.get_email_settings()
|
||||||
|
sent_copy = imap_session(settings) if settings["imap_sent_enabled"] else nullcontext(None)
|
||||||
|
with smtp_session(settings) as smtp_client, sent_copy as imap_client:
|
||||||
|
send_via_smtp(smtp_client, content)
|
||||||
|
delivered = True
|
||||||
|
if imap_client is not None:
|
||||||
|
ensure_imap_folder(imap_client, settings["imap_sent_folder"])
|
||||||
|
append_message(
|
||||||
|
imap_client, content, folder=settings["imap_sent_folder"], flags=r"(\Seen)"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
settings = repository.get_email_settings()
|
||||||
|
with imap_session(settings) as client:
|
||||||
|
ensure_imap_folder(client, settings["imap_drafts_folder"])
|
||||||
|
append_message(
|
||||||
|
client, content, folder=settings["imap_drafts_folder"], flags=r"(\Draft)"
|
||||||
|
)
|
||||||
|
delivered = True
|
||||||
|
os.replace(archive_temp, archive_path)
|
||||||
|
except Exception as exc:
|
||||||
|
if archive_temp is not None:
|
||||||
|
archive_temp.unlink(missing_ok=True)
|
||||||
|
if not delivered:
|
||||||
|
raise
|
||||||
|
archive_failure = exc
|
||||||
|
|
||||||
|
digest = hashlib.sha256(content).hexdigest()
|
||||||
|
references: dict[str, str] = {}
|
||||||
|
if archive_failure is None:
|
||||||
|
references["document"] = archive_path.relative_to(
|
||||||
|
repository.members_root / member_id / "files"
|
||||||
|
).as_posix()
|
||||||
|
event_data = {
|
||||||
|
"recipient": member.email,
|
||||||
|
"sha256": digest,
|
||||||
|
"claim_ids": [str(claim.get("claim_id", "")) for claim in claims],
|
||||||
|
"delivery_mode": delivery_mode,
|
||||||
|
}
|
||||||
|
if archive_failure is not None:
|
||||||
|
event_data["archive_error"] = str(archive_failure)
|
||||||
|
repository.append_event(
|
||||||
|
member_id,
|
||||||
|
event_type="welcome_email_sent",
|
||||||
|
summary="Willkommens- und Erstrechnungsmail erzeugt",
|
||||||
|
actor_type="user",
|
||||||
|
actor_name="Vorstand",
|
||||||
|
references=references,
|
||||||
|
data=event_data,
|
||||||
|
)
|
||||||
|
if archive_failure is not None:
|
||||||
|
raise RepositoryError(
|
||||||
|
"Die Willkommensmail wurde zugestellt bzw. abgelegt, konnte aber nicht "
|
||||||
|
f"vollständig archiviert werden: {archive_failure}. Bitte die Mitgliedsakte "
|
||||||
|
"manuell prüfen."
|
||||||
|
) from archive_failure
|
||||||
|
return GeneratedWelcomeMail(export_path, archive_path, digest)
|
||||||
|
|
||||||
|
|
||||||
|
def _available_path(directory: Path, filename: str) -> Path:
|
||||||
|
candidate = directory / filename
|
||||||
|
if not candidate.exists():
|
||||||
|
return candidate
|
||||||
|
stem, suffix = Path(filename).stem, Path(filename).suffix
|
||||||
|
index = 2
|
||||||
|
while (directory / f"{stem}-{index}{suffix}").exists():
|
||||||
|
index += 1
|
||||||
|
return directory / f"{stem}-{index}{suffix}"
|
||||||
@@ -63,6 +63,20 @@ def write_json_atomic(path: Path, data: Any) -> None:
|
|||||||
temporary.unlink(missing_ok=True)
|
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:
|
def read_json(path: Path) -> Any:
|
||||||
with path.open("r", encoding="utf-8") as handle:
|
with path.open("r", encoding="utf-8") as handle:
|
||||||
return json.load(handle)
|
return json.load(handle)
|
||||||
|
|||||||
@@ -35,6 +35,16 @@ from ccma.domain.dates import (
|
|||||||
parse_month_input,
|
parse_month_input,
|
||||||
validate_member_dates,
|
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 (
|
from ccma.domain.models import (
|
||||||
ASSET_CUSTODY_TYPE_LABELS,
|
ASSET_CUSTODY_TYPE_LABELS,
|
||||||
ASSET_OWNER_TYPE_LABELS,
|
ASSET_OWNER_TYPE_LABELS,
|
||||||
@@ -46,7 +56,12 @@ from ccma.domain.models import (
|
|||||||
Event,
|
Event,
|
||||||
Member,
|
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):
|
class RepositoryError(RuntimeError):
|
||||||
@@ -241,6 +256,18 @@ class MemberRepository:
|
|||||||
destination = templates_root / destination_name
|
destination = templates_root / destination_name
|
||||||
if source.is_file() and not destination.exists():
|
if source.is_file() and not destination.exists():
|
||||||
shutil.copyfile(source, destination)
|
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"
|
config_path = self.root / "repository.json"
|
||||||
if not config_path.exists():
|
if not config_path.exists():
|
||||||
self._write_json(config_path, DEFAULT_CONFIGURATION)
|
self._write_json(config_path, DEFAULT_CONFIGURATION)
|
||||||
@@ -2784,6 +2811,44 @@ class MemberRepository:
|
|||||||
}
|
}
|
||||||
self._write_json(self.root / "repository.json", config)
|
self._write_json(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 -- on a
|
||||||
|
read-only store the fallback is simply used without storing it."""
|
||||||
|
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:
|
||||||
|
self.assert_writable()
|
||||||
|
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:
|
def save_organization(self, values: dict[str, str]) -> None:
|
||||||
organization = {key: str(value).strip() for key, value in values.items()}
|
organization = {key: str(value).strip() for key, value in values.items()}
|
||||||
organization["iban"] = normalize_iban(organization.get("iban", ""))
|
organization["iban"] = normalize_iban(organization.get("iban", ""))
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ from ccma.ui.labels import display_label, storage_key
|
|||||||
from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage
|
from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage
|
||||||
from ccma.ui.payment_dialog import PaymentCreateDialog, PaymentEditDialog
|
from ccma.ui.payment_dialog import PaymentCreateDialog, PaymentEditDialog
|
||||||
from ccma.ui.scrolling import ScrollableFrame
|
from ccma.ui.scrolling import ScrollableFrame
|
||||||
|
from ccma.ui.welcome_mail_dialog import WelcomeMailDialog
|
||||||
|
|
||||||
# Shared column sizing for the ledger-style tables (Forderungen/Zahlungen/Spenden): date-
|
# Shared column sizing for the ledger-style tables (Forderungen/Zahlungen/Spenden): date-
|
||||||
# and amount-like columns get the same fixed width, status columns get a third more
|
# and amount-like columns get the same fixed width, status columns get a third more
|
||||||
@@ -355,6 +356,13 @@ class MemberTab(ttk.Frame):
|
|||||||
self.claims.grid(row=1, column=0, sticky="nsew")
|
self.claims.grid(row=1, column=0, sticky="nsew")
|
||||||
self.claims.bind("<Double-1>", lambda _event: self._open_selected_claim())
|
self.claims.bind("<Double-1>", lambda _event: self._open_selected_claim())
|
||||||
self.claims.bind("<Return>", lambda _event: self._open_selected_claim())
|
self.claims.bind("<Return>", lambda _event: self._open_selected_claim())
|
||||||
|
claim_actions = ttk.Frame(contribution_tab)
|
||||||
|
claim_actions.grid(row=2, column=0, sticky="e", pady=(8, 0))
|
||||||
|
ttk.Button(
|
||||||
|
claim_actions,
|
||||||
|
text="Willkommens- und Erstrechnungsmail",
|
||||||
|
command=self._send_welcome_mail,
|
||||||
|
).pack(side="left")
|
||||||
|
|
||||||
contribution_override_tab.columnconfigure(0, weight=1)
|
contribution_override_tab.columnconfigure(0, weight=1)
|
||||||
contribution_override_tab.rowconfigure(1, weight=1)
|
contribution_override_tab.rowconfigure(1, weight=1)
|
||||||
@@ -1185,6 +1193,14 @@ class MemberTab(ttk.Frame):
|
|||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
messagebox.showerror("Datei konnte nicht geöffnet werden", str(exc), parent=self)
|
messagebox.showerror("Datei konnte nicht geöffnet werden", str(exc), parent=self)
|
||||||
|
|
||||||
|
def _send_welcome_mail(self) -> None:
|
||||||
|
WelcomeMailDialog(self, self.repository, self.member_id, self._welcome_mail_sent)
|
||||||
|
|
||||||
|
def _welcome_mail_sent(self) -> None:
|
||||||
|
self._refresh_documents()
|
||||||
|
self._refresh_events()
|
||||||
|
self.on_changed()
|
||||||
|
|
||||||
def _create_document(self) -> None:
|
def _create_document(self) -> None:
|
||||||
DocumentTemplateDialog(
|
DocumentTemplateDialog(
|
||||||
self,
|
self,
|
||||||
|
|||||||
@@ -7,6 +7,14 @@ from tkinter import filedialog, messagebox, ttk
|
|||||||
|
|
||||||
from ccma.config import AppConfig
|
from ccma.config import AppConfig
|
||||||
from ccma.domain.contributions import decimal_value, money_text
|
from ccma.domain.contributions import decimal_value, money_text
|
||||||
|
from ccma.domain.mail_templates import (
|
||||||
|
MAIL_TEMPLATES,
|
||||||
|
MailTemplateError,
|
||||||
|
default_mail_template,
|
||||||
|
placeholder_entries,
|
||||||
|
template_spec,
|
||||||
|
validate_mail_template,
|
||||||
|
)
|
||||||
from ccma.domain.models import HOUSEKEEPER_MEMBER_FIELD_LABELS
|
from ccma.domain.models import HOUSEKEEPER_MEMBER_FIELD_LABELS
|
||||||
from ccma.services.intervals import (
|
from ccma.services.intervals import (
|
||||||
IntervalValidationError,
|
IntervalValidationError,
|
||||||
@@ -67,6 +75,15 @@ class OptionsDialog(tk.Toplevel):
|
|||||||
"website", "iban", "bic", "creditor_id",
|
"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.mail_template_snippets: dict[str, str] = {}
|
||||||
self.title("Optionen")
|
self.title("Optionen")
|
||||||
self.transient(master.winfo_toplevel())
|
self.transient(master.winfo_toplevel())
|
||||||
self.grab_set()
|
self.grab_set()
|
||||||
@@ -102,6 +119,7 @@ class OptionsDialog(tk.Toplevel):
|
|||||||
automation = ttk.Frame(notebook, padding=16)
|
automation = ttk.Frame(notebook, padding=16)
|
||||||
reminders = ttk.Frame(notebook, padding=16)
|
reminders = ttk.Frame(notebook, padding=16)
|
||||||
email = ttk.Frame(notebook, padding=16)
|
email = ttk.Frame(notebook, padding=16)
|
||||||
|
mail_templates = ttk.Frame(notebook, padding=16)
|
||||||
changelog = ChangelogView(notebook)
|
changelog = ChangelogView(notebook)
|
||||||
notebook.add(paths, text="Pfade")
|
notebook.add(paths, text="Pfade")
|
||||||
notebook.add(appearance, text="Darstellung")
|
notebook.add(appearance, text="Darstellung")
|
||||||
@@ -110,6 +128,7 @@ class OptionsDialog(tk.Toplevel):
|
|||||||
notebook.add(automation, text="Hausmeister")
|
notebook.add(automation, text="Hausmeister")
|
||||||
notebook.add(reminders, text="Mahnungen")
|
notebook.add(reminders, text="Mahnungen")
|
||||||
notebook.add(email, text="E-Mail-Versand")
|
notebook.add(email, text="E-Mail-Versand")
|
||||||
|
notebook.add(mail_templates, text="E-Mail-Vorlagen")
|
||||||
notebook.add(changelog, text="Changelog")
|
notebook.add(changelog, text="Changelog")
|
||||||
self._build_paths(paths)
|
self._build_paths(paths)
|
||||||
self._build_appearance(appearance)
|
self._build_appearance(appearance)
|
||||||
@@ -118,6 +137,7 @@ class OptionsDialog(tk.Toplevel):
|
|||||||
self._build_automation(automation)
|
self._build_automation(automation)
|
||||||
self._build_reminders(reminders)
|
self._build_reminders(reminders)
|
||||||
self._build_email(email)
|
self._build_email(email)
|
||||||
|
self._build_mail_templates(mail_templates)
|
||||||
|
|
||||||
buttons = ttk.Frame(root)
|
buttons = ttk.Frame(root)
|
||||||
buttons.grid(row=1, column=0, sticky="e", pady=(12, 0))
|
buttons.grid(row=1, column=0, sticky="e", pady=(12, 0))
|
||||||
@@ -912,6 +932,7 @@ class OptionsDialog(tk.Toplevel):
|
|||||||
standard_fee_items=self.standard_items,
|
standard_fee_items=self.standard_items,
|
||||||
)
|
)
|
||||||
self.repository.save_email_settings(**email_settings)
|
self.repository.save_email_settings(**email_settings)
|
||||||
|
self._save_mail_templates()
|
||||||
except (OSError, RepositoryError) as exc:
|
except (OSError, RepositoryError) as exc:
|
||||||
messagebox.showerror("Optionen konnten nicht gespeichert werden", str(exc), parent=self)
|
messagebox.showerror("Optionen konnten nicht gespeichert werden", str(exc), parent=self)
|
||||||
return
|
return
|
||||||
@@ -919,14 +940,191 @@ class OptionsDialog(tk.Toplevel):
|
|||||||
messagebox.showwarning(
|
messagebox.showwarning(
|
||||||
"Store ist schreibgeschützt",
|
"Store ist schreibgeschützt",
|
||||||
"Die Programmeinstellungen wurden gespeichert. Vereinsdaten, "
|
"Die Programmeinstellungen wurden gespeichert. Vereinsdaten, "
|
||||||
"Mitgliedsnummern, Mahnungen und E-Mail-Versand konnten nicht gespeichert "
|
"Mitgliedsnummern, Mahnungen, E-Mail-Versand und E-Mail-Vorlagen konnten "
|
||||||
"werden, weil der Mitglieder-Store nur lesend eingebunden ist.",
|
"nicht gespeichert werden, weil der Mitglieder-Store nur lesend "
|
||||||
|
"eingebunden ist.",
|
||||||
parent=self,
|
parent=self,
|
||||||
)
|
)
|
||||||
if self.on_saved:
|
if self.on_saved:
|
||||||
self.on_saved(store_changed)
|
self.on_saved(store_changed)
|
||||||
self.destroy()
|
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())
|
||||||
|
# The row text is a readable label; what actually gets inserted is the entry's
|
||||||
|
# snippet, which for a repeat block is the whole block and not its description.
|
||||||
|
self.mail_template_snippets = {}
|
||||||
|
for entry in placeholder_entries(key):
|
||||||
|
label = f" {entry.label}" if entry.indented else entry.label
|
||||||
|
row = self.mail_template_placeholders.insert(
|
||||||
|
"", "end", values=(label, entry.description)
|
||||||
|
)
|
||||||
|
self.mail_template_snippets[row] = entry.snippet
|
||||||
|
|
||||||
|
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
|
||||||
|
snippet = self.mail_template_snippets.get(selected[0])
|
||||||
|
if not snippet:
|
||||||
|
return
|
||||||
|
# A multi-line block only works on lines of its own, so it starts on a fresh
|
||||||
|
# one instead of being appended to whatever the cursor sat behind.
|
||||||
|
if "\n" in snippet.strip() and not self._mail_cursor_at_line_start():
|
||||||
|
snippet = "\n" + snippet
|
||||||
|
self.mail_template_body.insert("insert", snippet)
|
||||||
|
self.mail_template_body.focus_set()
|
||||||
|
|
||||||
|
def _mail_cursor_at_line_start(self) -> bool:
|
||||||
|
return str(self.mail_template_body.index("insert")).split(".")[1] == "0"
|
||||||
|
|
||||||
|
def _save_mail_templates(self) -> None:
|
||||||
|
self._capture_mail_template()
|
||||||
|
pending = {
|
||||||
|
key: value
|
||||||
|
for key, value in self.mail_template_edits.items()
|
||||||
|
if self.mail_template_original.get(key) != value
|
||||||
|
}
|
||||||
|
# Check every edited template before writing the first one: a mistake in one
|
||||||
|
# must not leave the others half-saved.
|
||||||
|
for key, (subject, body) in pending.items():
|
||||||
|
try:
|
||||||
|
validate_mail_template(key, subject, body)
|
||||||
|
except MailTemplateError as exc:
|
||||||
|
raise RepositoryError(f"{template_spec(key).label}: {exc}") from exc
|
||||||
|
for key, (subject, body) in pending.items():
|
||||||
|
self.repository.save_mail_template(key, subject=subject, body=body)
|
||||||
|
|
||||||
def _center_on_parent(self) -> None:
|
def _center_on_parent(self) -> None:
|
||||||
self.update_idletasks()
|
self.update_idletasks()
|
||||||
parent = self.master.winfo_toplevel()
|
parent = self.master.winfo_toplevel()
|
||||||
|
|||||||
@@ -0,0 +1,241 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tkinter as tk
|
||||||
|
from collections.abc import Callable
|
||||||
|
from decimal import Decimal
|
||||||
|
from email.parser import BytesParser
|
||||||
|
from email.policy import default as default_policy
|
||||||
|
from tkinter import filedialog, messagebox, ttk
|
||||||
|
|
||||||
|
from ccma.domain.contributions import claim_balance, money_text
|
||||||
|
from ccma.domain.dates import format_date_for_display
|
||||||
|
from ccma.services.welcome_mail import (
|
||||||
|
generate_and_send_welcome_mail,
|
||||||
|
open_claims,
|
||||||
|
welcome_mail_bytes,
|
||||||
|
)
|
||||||
|
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||||
|
from ccma.ui.email_delivery_dialog import resolve_delivery_mode
|
||||||
|
|
||||||
|
|
||||||
|
class WelcomeMailDialog(tk.Toplevel):
|
||||||
|
"""Sends the welcome mail that doubles as the first invoice for a new member:
|
||||||
|
membership start, member number and every claim that is still open."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
master: tk.Misc,
|
||||||
|
repository: MemberRepository,
|
||||||
|
member_id: str,
|
||||||
|
on_sent: Callable[[], None],
|
||||||
|
):
|
||||||
|
super().__init__(master)
|
||||||
|
self.repository = repository
|
||||||
|
self.member_id = member_id
|
||||||
|
self.on_sent = on_sent
|
||||||
|
self.claims: list[dict] = []
|
||||||
|
organization = repository.get_configuration().get("organization") or {}
|
||||||
|
self.sender_name_var = tk.StringVar(value=str(organization.get("name") or "Verwaltung C3MA"))
|
||||||
|
self.sender_email_var = tk.StringVar(value=str(organization.get("email") or ""))
|
||||||
|
self.summary_var = tk.StringVar()
|
||||||
|
self.title("Willkommens- und Erstrechnungsmail")
|
||||||
|
self.transient(master.winfo_toplevel())
|
||||||
|
self.resizable(True, True)
|
||||||
|
self._build_ui()
|
||||||
|
self._refresh_claims()
|
||||||
|
self.grab_set()
|
||||||
|
|
||||||
|
def _build_ui(self) -> None:
|
||||||
|
self.columnconfigure(0, weight=1)
|
||||||
|
self.rowconfigure(2, weight=1)
|
||||||
|
ttk.Label(
|
||||||
|
self,
|
||||||
|
text=(
|
||||||
|
"Begrüßt das neue Mitglied und stellt die noch offenen Forderungen "
|
||||||
|
"(Aufnahmegebühr und erster Beitrag) als Erstrechnung zusammen. Text und "
|
||||||
|
"Betreff stammen aus der Vorlage unter Optionen → E-Mail-Vorlagen."
|
||||||
|
),
|
||||||
|
wraplength=640,
|
||||||
|
).grid(row=0, column=0, sticky="w", padx=14, pady=(14, 8))
|
||||||
|
ttk.Label(
|
||||||
|
self,
|
||||||
|
text=(
|
||||||
|
"Standardmäßig sind alle offenen Forderungen ausgewählt – einzelne mit "
|
||||||
|
"Strg+Klick abwählen."
|
||||||
|
),
|
||||||
|
style="Muted.TLabel",
|
||||||
|
wraplength=640,
|
||||||
|
).grid(row=1, column=0, sticky="w", padx=14, pady=(0, 8))
|
||||||
|
table = ttk.Frame(self)
|
||||||
|
table.grid(row=2, column=0, sticky="nsew", padx=14)
|
||||||
|
table.columnconfigure(0, weight=1)
|
||||||
|
table.rowconfigure(0, weight=1)
|
||||||
|
self.table = ttk.Treeview(
|
||||||
|
table,
|
||||||
|
columns=("due", "amount", "title"),
|
||||||
|
show="headings",
|
||||||
|
selectmode="extended",
|
||||||
|
height=6,
|
||||||
|
)
|
||||||
|
for key, title, width in (
|
||||||
|
("due", "Fällig", 90),
|
||||||
|
("amount", "Offen", 90),
|
||||||
|
("title", "Forderung", 320),
|
||||||
|
):
|
||||||
|
self.table.heading(key, text=title)
|
||||||
|
self.table.column(key, width=width, anchor="w", stretch=key == "title")
|
||||||
|
self.table.grid(row=0, column=0, sticky="nsew")
|
||||||
|
scroll = ttk.Scrollbar(table, orient="vertical", command=self.table.yview)
|
||||||
|
scroll.grid(row=0, column=1, sticky="ns")
|
||||||
|
self.table.configure(yscrollcommand=scroll.set)
|
||||||
|
self.table.bind("<<TreeviewSelect>>", lambda _event: self._update_summary())
|
||||||
|
ttk.Label(self, textvariable=self.summary_var, style="Mono.TLabel").grid(
|
||||||
|
row=3, column=0, sticky="w", padx=14, pady=(6, 10)
|
||||||
|
)
|
||||||
|
|
||||||
|
fields = ttk.Frame(self)
|
||||||
|
fields.grid(row=4, column=0, sticky="ew", padx=14)
|
||||||
|
fields.columnconfigure(1, weight=1)
|
||||||
|
ttk.Label(fields, text="Absendername").grid(row=0, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||||
|
ttk.Entry(fields, textvariable=self.sender_name_var, width=52).grid(row=0, column=1, sticky="ew")
|
||||||
|
ttk.Label(fields, text="Absenderadresse").grid(row=1, column=0, sticky="w", pady=5, padx=(0, 12))
|
||||||
|
ttk.Entry(fields, textvariable=self.sender_email_var).grid(row=1, column=1, sticky="ew")
|
||||||
|
ttk.Label(fields, text="Signatur").grid(row=2, column=0, sticky="nw", pady=5, padx=(0, 12))
|
||||||
|
self.signature = tk.Text(fields, width=52, height=4, wrap="word")
|
||||||
|
self.signature.grid(row=2, column=1, sticky="ew", pady=5)
|
||||||
|
self.signature.insert("1.0", "Der Vorstand\nChaos Computer Club Mannheim e.V.")
|
||||||
|
|
||||||
|
buttons = ttk.Frame(self, padding=14)
|
||||||
|
buttons.grid(row=5, column=0, sticky="e")
|
||||||
|
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left")
|
||||||
|
ttk.Button(buttons, text="Vorschau", command=self._preview).pack(side="left", padx=(8, 0))
|
||||||
|
ttk.Button(
|
||||||
|
buttons, text="E-Mail erzeugen", style="Accent.TButton", command=self._generate
|
||||||
|
).pack(side="left", padx=(8, 0))
|
||||||
|
|
||||||
|
def _refresh_claims(self) -> None:
|
||||||
|
try:
|
||||||
|
self.claims = open_claims(self.repository, self.member_id)
|
||||||
|
data = self.repository.get_contributions(self.member_id)
|
||||||
|
except RepositoryError as exc:
|
||||||
|
messagebox.showerror("Forderungen konnten nicht gelesen werden", str(exc), parent=self)
|
||||||
|
return
|
||||||
|
self.table.delete(*self.table.get_children())
|
||||||
|
for index, claim in enumerate(self.claims):
|
||||||
|
self.table.insert(
|
||||||
|
"",
|
||||||
|
"end",
|
||||||
|
iid=str(index),
|
||||||
|
values=(
|
||||||
|
format_date_for_display(str(claim.get("due_date", ""))),
|
||||||
|
money_text(claim_balance(data, claim)),
|
||||||
|
str(claim.get("description", "") or claim.get("title", "")),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
self.table.selection_set(self.table.get_children())
|
||||||
|
self._update_summary()
|
||||||
|
|
||||||
|
def _selected_claims(self) -> list[dict]:
|
||||||
|
return [self.claims[int(iid)] for iid in self.table.selection()]
|
||||||
|
|
||||||
|
def _update_summary(self) -> None:
|
||||||
|
data = self.repository.get_contributions(self.member_id)
|
||||||
|
selected = self._selected_claims()
|
||||||
|
total = sum((claim_balance(data, claim) for claim in selected), Decimal("0"))
|
||||||
|
self.summary_var.set(
|
||||||
|
f"{len(selected)} von {len(self.claims)} Forderungen ausgewählt – "
|
||||||
|
f"Gesamtbetrag {money_text(total)} Euro"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _mail_bytes(self) -> bytes:
|
||||||
|
member = self.repository.get_member(self.member_id)
|
||||||
|
data = self.repository.get_contributions(self.member_id)
|
||||||
|
claims = self._selected_claims()
|
||||||
|
return welcome_mail_bytes(
|
||||||
|
self.repository,
|
||||||
|
member=member,
|
||||||
|
claims=claims,
|
||||||
|
balances={
|
||||||
|
str(claim.get("claim_id", "")): claim_balance(data, claim) for claim in claims
|
||||||
|
},
|
||||||
|
organization=self.repository.get_configuration().get("organization") or {},
|
||||||
|
sender_name=self.sender_name_var.get(),
|
||||||
|
sender_email=self.sender_email_var.get(),
|
||||||
|
signature=self.signature.get("1.0", "end-1c"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def _preview(self) -> None:
|
||||||
|
try:
|
||||||
|
message = BytesParser(policy=default_policy).parsebytes(self._mail_bytes())
|
||||||
|
except (OSError, RepositoryError) as exc:
|
||||||
|
messagebox.showerror("Vorschau nicht möglich", str(exc), parent=self)
|
||||||
|
return
|
||||||
|
window = tk.Toplevel(self)
|
||||||
|
window.title("Vorschau der Willkommensmail")
|
||||||
|
window.transient(self)
|
||||||
|
window.columnconfigure(0, weight=1)
|
||||||
|
window.rowconfigure(1, weight=1)
|
||||||
|
ttk.Label(window, text=f"Betreff: {message['Subject']}", padding=12).grid(
|
||||||
|
row=0, column=0, sticky="w"
|
||||||
|
)
|
||||||
|
preview = tk.Text(window, wrap="word", width=88, height=28)
|
||||||
|
preview.grid(row=1, column=0, sticky="nsew", padx=12, pady=(0, 12))
|
||||||
|
preview.insert("1.0", message.get_content())
|
||||||
|
preview.configure(state="disabled")
|
||||||
|
scroll = ttk.Scrollbar(window, orient="vertical", command=preview.yview)
|
||||||
|
scroll.grid(row=1, column=1, sticky="ns", pady=(0, 12), padx=(0, 12))
|
||||||
|
preview.configure(yscrollcommand=scroll.set)
|
||||||
|
ttk.Button(window, text="Schließen", command=window.destroy).grid(
|
||||||
|
row=2, column=0, columnspan=2, sticky="e", padx=12, pady=(0, 12)
|
||||||
|
)
|
||||||
|
|
||||||
|
def _generate(self) -> None:
|
||||||
|
selected = self._selected_claims()
|
||||||
|
if not selected and not messagebox.askyesno(
|
||||||
|
"Keine Forderung ausgewählt",
|
||||||
|
"Es ist keine Forderung ausgewählt. Soll die Mail ohne Rechnungspositionen "
|
||||||
|
"erzeugt werden?",
|
||||||
|
parent=self,
|
||||||
|
):
|
||||||
|
return
|
||||||
|
delivery_mode = resolve_delivery_mode(self, self.repository)
|
||||||
|
if delivery_mode is None:
|
||||||
|
return
|
||||||
|
output_path = None
|
||||||
|
if delivery_mode == "local":
|
||||||
|
chosen = filedialog.asksaveasfilename(
|
||||||
|
parent=self,
|
||||||
|
title="Willkommensmail als Thunderbird-Entwurf speichern",
|
||||||
|
defaultextension=".eml",
|
||||||
|
initialfile="Willkommen.eml",
|
||||||
|
filetypes=[("E-Mail-Datei", "*.eml"), ("Alle Dateien", "*.*")],
|
||||||
|
)
|
||||||
|
if not chosen:
|
||||||
|
return
|
||||||
|
output_path = chosen
|
||||||
|
try:
|
||||||
|
result = generate_and_send_welcome_mail(
|
||||||
|
self.repository,
|
||||||
|
self.member_id,
|
||||||
|
claim_ids=[str(claim.get("claim_id", "")) for claim in selected],
|
||||||
|
delivery_mode=delivery_mode,
|
||||||
|
output_path=output_path,
|
||||||
|
sender_name=self.sender_name_var.get(),
|
||||||
|
sender_email=self.sender_email_var.get(),
|
||||||
|
signature=self.signature.get("1.0", "end-1c"),
|
||||||
|
)
|
||||||
|
except (OSError, RepositoryError) as exc:
|
||||||
|
messagebox.showerror("E-Mail konnte nicht erzeugt werden", str(exc), parent=self)
|
||||||
|
return
|
||||||
|
outcome = {
|
||||||
|
"local": f"gespeichert:\n{result.export_path}",
|
||||||
|
"send": "direkt versandt.",
|
||||||
|
"drafts": "als Entwurf im Postfach abgelegt.",
|
||||||
|
}[delivery_mode]
|
||||||
|
messagebox.showinfo(
|
||||||
|
"Willkommensmail erzeugt",
|
||||||
|
f"Die E-Mail wurde {outcome}\n\nEine Kopie liegt in der Mitgliederakte.",
|
||||||
|
parent=self,
|
||||||
|
)
|
||||||
|
self.destroy()
|
||||||
|
self.on_sent()
|
||||||
@@ -129,6 +129,7 @@ def test_default_templates_are_seeded_without_overwriting_store_version(tmp_path
|
|||||||
"Forderung mit Positionen.fodt",
|
"Forderung mit Positionen.fodt",
|
||||||
"Mahnung.fodt",
|
"Mahnung.fodt",
|
||||||
"Mitglied.fodt",
|
"Mitglied.fodt",
|
||||||
|
"mail",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from ccma.domain.mail_templates import (
|
||||||
|
MAIL_TEMPLATES,
|
||||||
|
MailTemplate,
|
||||||
|
MailTemplateBlock,
|
||||||
|
MailTemplateError,
|
||||||
|
MailTemplateSpec,
|
||||||
|
default_mail_template,
|
||||||
|
parse_mail_template,
|
||||||
|
placeholder_entries,
|
||||||
|
render_mail_template,
|
||||||
|
serialize_mail_template,
|
||||||
|
validate_block_markers,
|
||||||
|
validate_mail_template,
|
||||||
|
)
|
||||||
|
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||||
|
|
||||||
|
|
||||||
|
def test_subject_header_round_trips_through_file_format() -> None:
|
||||||
|
text = serialize_mail_template("Hallo {{member.first_name}}", "Zeile eins\nZeile zwei")
|
||||||
|
|
||||||
|
template = parse_mail_template(text)
|
||||||
|
|
||||||
|
assert text.startswith("Betreff: Hallo {{member.first_name}}\n\n")
|
||||||
|
assert template == MailTemplate("Hallo {{member.first_name}}", "Zeile eins\nZeile zwei")
|
||||||
|
|
||||||
|
|
||||||
|
def test_shipped_defaults_only_use_declared_placeholders() -> None:
|
||||||
|
for spec in MAIL_TEMPLATES:
|
||||||
|
template = default_mail_template(spec.key)
|
||||||
|
validate_mail_template(spec.key, template.subject, template.body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_rendering_fills_values_and_expands_repeat_blocks() -> None:
|
||||||
|
template = MailTemplate(
|
||||||
|
"Beitrag für {{member.first_name}}",
|
||||||
|
"Hallo {{member.first_name}},\n"
|
||||||
|
"{{#claims}}- {{claim.title}}: {{claim.amount}}{{/claims}}\n"
|
||||||
|
"Summe: {{claims.total}}",
|
||||||
|
)
|
||||||
|
|
||||||
|
rendered = render_mail_template(
|
||||||
|
template,
|
||||||
|
{"member.first_name": "Ada", "claims.total": "75.00"},
|
||||||
|
{
|
||||||
|
"claims": [
|
||||||
|
{"claim.title": "Aufnahmegebühr", "claim.amount": "15.00"},
|
||||||
|
{"claim.title": "Mitgliedsbeitrag", "claim.amount": "60.00"},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert rendered.subject == "Beitrag für Ada"
|
||||||
|
assert rendered.body == (
|
||||||
|
"Hallo Ada,\n- Aufnahmegebühr: 15.00\n- Mitgliedsbeitrag: 60.00\nSumme: 75.00"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_line_holding_only_an_empty_placeholder_is_dropped() -> None:
|
||||||
|
template = MailTemplate("Betreff", "Erste Zeile\n{{reminder.hint}}\nLetzte Zeile")
|
||||||
|
|
||||||
|
assert render_mail_template(template, {"reminder.hint": ""}).body == "Erste Zeile\nLetzte Zeile"
|
||||||
|
assert (
|
||||||
|
render_mail_template(template, {"reminder.hint": "Hinweis: X"}).body
|
||||||
|
== "Erste Zeile\nHinweis: X\nLetzte Zeile"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_placeholder_is_rejected_while_editing() -> None:
|
||||||
|
with pytest.raises(MailTemplateError, match="member.favourite_colour"):
|
||||||
|
validate_mail_template("welcome", "Betreff", "Hallo {{member.favourite_colour}}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_store_copies_are_seeded_editable_and_resettable(tmp_path) -> None:
|
||||||
|
repository = MemberRepository(tmp_path)
|
||||||
|
repository.initialize()
|
||||||
|
path = repository.mail_templates_root / "willkommen.txt"
|
||||||
|
assert path.is_file()
|
||||||
|
|
||||||
|
repository.save_mail_template("welcome", subject="Moin {{member.first_name}}", body="Kurz und knapp.")
|
||||||
|
assert repository.get_mail_template("welcome") == MailTemplate(
|
||||||
|
"Moin {{member.first_name}}", "Kurz und knapp."
|
||||||
|
)
|
||||||
|
assert "Moin {{member.first_name}}" in path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
repository.reset_mail_template("welcome")
|
||||||
|
assert repository.get_mail_template("welcome") == default_mail_template("welcome")
|
||||||
|
|
||||||
|
|
||||||
|
def test_missing_template_file_falls_back_to_shipped_default(tmp_path) -> None:
|
||||||
|
repository = MemberRepository(tmp_path)
|
||||||
|
repository.initialize()
|
||||||
|
(repository.mail_templates_root / "mahnung.txt").unlink()
|
||||||
|
|
||||||
|
template = repository.get_mail_template("reminder")
|
||||||
|
|
||||||
|
assert template == default_mail_template("reminder")
|
||||||
|
assert (repository.mail_templates_root / "mahnung.txt").is_file()
|
||||||
|
|
||||||
|
|
||||||
|
def test_saving_a_broken_template_is_refused(tmp_path) -> None:
|
||||||
|
repository = MemberRepository(tmp_path)
|
||||||
|
repository.initialize()
|
||||||
|
|
||||||
|
with pytest.raises(RepositoryError, match="Platzhalter"):
|
||||||
|
repository.save_mail_template("reminder", subject="Mahnung", body="Hallo {{member.nonsense}}")
|
||||||
|
with pytest.raises(RepositoryError, match="Betreff"):
|
||||||
|
repository.save_mail_template("reminder", subject=" ", body="Hallo")
|
||||||
|
|
||||||
|
|
||||||
|
def test_block_rows_insert_a_working_block_not_its_description() -> None:
|
||||||
|
entries = placeholder_entries("welcome")
|
||||||
|
block = next(entry for entry in entries if entry.label.startswith("{{#claims}}"))
|
||||||
|
|
||||||
|
# The label may abbreviate the block, the inserted snippet may not.
|
||||||
|
assert block.label == "{{#claims}} … {{/claims}}"
|
||||||
|
assert block.snippet.startswith("{{#claims}}\n")
|
||||||
|
assert block.snippet.rstrip().endswith("{{/claims}}")
|
||||||
|
assert "{{claim.description}}" in block.snippet
|
||||||
|
assert "…" not in block.snippet
|
||||||
|
assert all("…" not in entry.snippet for entry in entries)
|
||||||
|
|
||||||
|
|
||||||
|
def test_inserted_block_validates_and_renders_one_line_per_claim() -> None:
|
||||||
|
block = next(
|
||||||
|
entry for entry in placeholder_entries("welcome") if entry.label.startswith("{{#claims}}")
|
||||||
|
)
|
||||||
|
body = f"Hallo {{{{member.first_name}}}},\n\n{block.snippet}\nSumme: {{{{claims.total}}}}"
|
||||||
|
|
||||||
|
validate_mail_template("welcome", "Betreff", body)
|
||||||
|
rendered = render_mail_template(
|
||||||
|
MailTemplate("Betreff", body),
|
||||||
|
{"member.first_name": "Ada", "claims.total": "75.00"},
|
||||||
|
{
|
||||||
|
"claims": [
|
||||||
|
{
|
||||||
|
"claim.description": "Aufnahmegebühr",
|
||||||
|
"claim.due_date": "17.09.2026",
|
||||||
|
"claim.balance": "15.00",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"claim.description": "Mitgliedsbeitrag 2. Halbjahr 2026",
|
||||||
|
"claim.due_date": "17.09.2026",
|
||||||
|
"claim.balance": "60.00",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
assert "Aufnahmegebühr (fällig 17.09.2026): 15.00 Euro" in rendered.body
|
||||||
|
assert "Mitgliedsbeitrag 2. Halbjahr 2026 (fällig 17.09.2026): 60.00 Euro" in rendered.body
|
||||||
|
assert "…" not in rendered.body
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_block_snippet_is_valid_in_its_own_template() -> None:
|
||||||
|
for spec in MAIL_TEMPLATES:
|
||||||
|
for block in spec.blocks:
|
||||||
|
validate_mail_template(spec.key, "Betreff", block.snippet)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("body", "expected"),
|
||||||
|
[
|
||||||
|
("Hallo\n{{#claims}}\n{{claim.title}}", "wird nicht geschlossen"),
|
||||||
|
("Hallo\n{{/claims}}\nGruß", "ohne zugehöriges"),
|
||||||
|
("{{#claims}}\n{{claim.title}}\n{{/claims}}\n{{/claims}}", "ohne zugehöriges"),
|
||||||
|
("{{#claims}}\n{{#claims}}\nx\n{{/claims}}\n{{/claims}}", "verschachtelt"),
|
||||||
|
("{{#members}}\nx\n{{/members}}", "Unbekannter Wiederholungsblock"),
|
||||||
|
("{{ # claims }}\nx", "wird nicht geschlossen"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_unbalanced_repeat_markers_are_rejected(body, expected) -> None:
|
||||||
|
with pytest.raises(MailTemplateError, match=expected):
|
||||||
|
validate_mail_template("welcome", "Betreff", body)
|
||||||
|
|
||||||
|
|
||||||
|
def test_repeat_markers_in_the_subject_are_rejected() -> None:
|
||||||
|
with pytest.raises(MailTemplateError, match="Betreff"):
|
||||||
|
validate_mail_template("welcome", "Beitrag {{#claims}}", "Hallo")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_template_without_blocks_rejects_every_marker() -> None:
|
||||||
|
with pytest.raises(MailTemplateError, match="Verfügbar: keine"):
|
||||||
|
validate_mail_template("sepa", "Betreff", "{{#claims}}\nx\n{{/claims}}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_closing_marker_must_match_the_open_one(monkeypatch) -> None:
|
||||||
|
two_blocks = MailTemplateSpec(
|
||||||
|
key="two-blocks",
|
||||||
|
label="Test",
|
||||||
|
filename="test.txt",
|
||||||
|
description="",
|
||||||
|
placeholders=(),
|
||||||
|
blocks=(
|
||||||
|
MailTemplateBlock("first", "", (("item.a", ""),), "{{item.a}}"),
|
||||||
|
MailTemplateBlock("second", "", (("item.b", ""),), "{{item.b}}"),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
monkeypatch.setattr("ccma.domain.mail_templates.MAIL_TEMPLATES", (two_blocks,))
|
||||||
|
|
||||||
|
with pytest.raises(MailTemplateError, match="Erwartet wird \\{\\{/first\\}\\}"):
|
||||||
|
validate_block_markers("two-blocks", "Betreff", "{{#first}}\nx\n{{/second}}")
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_broken_template_is_not_stored(tmp_path) -> None:
|
||||||
|
repository = MemberRepository(tmp_path)
|
||||||
|
repository.initialize()
|
||||||
|
path = repository.mail_templates_root / "willkommen.txt"
|
||||||
|
before = path.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
with pytest.raises(RepositoryError, match="wird nicht geschlossen"):
|
||||||
|
repository.save_mail_template(
|
||||||
|
"welcome", subject="Willkommen", body="Hallo\n{{#claims}}\n{{claim.title}}"
|
||||||
|
)
|
||||||
|
|
||||||
|
assert path.read_text(encoding="utf-8") == before
|
||||||
@@ -0,0 +1,177 @@
|
|||||||
|
from email.parser import BytesParser
|
||||||
|
from email.policy import default
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
tk = pytest.importorskip("tkinter")
|
||||||
|
|
||||||
|
from ccma.config import AppConfig # noqa: E402
|
||||||
|
from ccma.domain.mail_templates import placeholder_entries # noqa: E402
|
||||||
|
from ccma.services.welcome_mail import generate_and_send_welcome_mail # noqa: E402
|
||||||
|
from ccma.storage.repository import MemberRepository, RepositoryError # noqa: E402
|
||||||
|
|
||||||
|
|
||||||
|
# One root for the whole session: the icon library binds its images to the first
|
||||||
|
# Tk instance, so tearing a root down between tests would invalidate them.
|
||||||
|
@pytest.fixture(scope="session")
|
||||||
|
def tk_root():
|
||||||
|
from ccma.ui.theme import load_theme
|
||||||
|
|
||||||
|
try:
|
||||||
|
root = tk.Tk()
|
||||||
|
except tk.TclError as exc: # headless CI has no display to build widgets on
|
||||||
|
pytest.skip(f"kein Display verfügbar: {exc}")
|
||||||
|
root.withdraw()
|
||||||
|
load_theme(root, "dark")
|
||||||
|
yield root
|
||||||
|
root.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def repository(tmp_path):
|
||||||
|
repository = MemberRepository(tmp_path / "store")
|
||||||
|
repository.initialize()
|
||||||
|
organization = repository.get_configuration()["organization"]
|
||||||
|
organization.update(
|
||||||
|
{
|
||||||
|
"name": "Chaos Computer Club Mannheim e.V.",
|
||||||
|
"email": "verwaltung@example.org",
|
||||||
|
"iban": "DE98670505050038907751",
|
||||||
|
"bic": "MANSDE66XXX",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
repository.save_organization(organization)
|
||||||
|
return repository
|
||||||
|
|
||||||
|
|
||||||
|
def _open_options(tk_root, repository):
|
||||||
|
from ccma.ui.options_dialog import OptionsDialog
|
||||||
|
|
||||||
|
dialog = OptionsDialog(tk_root, AppConfig(store_path=str(repository.root)), repository)
|
||||||
|
tk_root.update()
|
||||||
|
return dialog
|
||||||
|
|
||||||
|
|
||||||
|
def _block_row(dialog):
|
||||||
|
for row in dialog.mail_template_placeholders.get_children():
|
||||||
|
label = str(dialog.mail_template_placeholders.item(row, "values")[0])
|
||||||
|
if label.startswith("{{#claims}}"):
|
||||||
|
return row
|
||||||
|
raise AssertionError("Der Wiederholungsblock fehlt in der Platzhalterliste.")
|
||||||
|
|
||||||
|
|
||||||
|
def test_double_click_on_a_block_is_wired_up(tk_root, repository):
|
||||||
|
dialog = _open_options(tk_root, repository)
|
||||||
|
try:
|
||||||
|
assert dialog.mail_template_placeholders.bind("<Double-1>")
|
||||||
|
assert dialog.mail_template_snippets[_block_row(dialog)] == next(
|
||||||
|
entry.snippet
|
||||||
|
for entry in placeholder_entries("welcome")
|
||||||
|
if entry.label.startswith("{{#claims}}")
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
dialog.grab_release()
|
||||||
|
dialog.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
def test_inserting_a_block_puts_editable_template_code_into_the_body(tk_root, repository):
|
||||||
|
dialog = _open_options(tk_root, repository)
|
||||||
|
try:
|
||||||
|
dialog.mail_template_body.delete("1.0", "end")
|
||||||
|
dialog.mail_template_body.insert("1.0", "Offen sind aktuell:")
|
||||||
|
dialog.mail_template_placeholders.selection_set(_block_row(dialog))
|
||||||
|
dialog._insert_mail_placeholder()
|
||||||
|
tk_root.update()
|
||||||
|
body = dialog.mail_template_body.get("1.0", "end-1c")
|
||||||
|
finally:
|
||||||
|
dialog.grab_release()
|
||||||
|
dialog.destroy()
|
||||||
|
|
||||||
|
assert "…" not in body
|
||||||
|
assert "{{#claims}}" in body and "{{/claims}}" in body
|
||||||
|
assert "{{claim.description}}" in body
|
||||||
|
# The block was appended behind existing text and still starts on its own line.
|
||||||
|
assert body.splitlines()[0] == "Offen sind aktuell:"
|
||||||
|
assert body.splitlines()[1] == "{{#claims}}"
|
||||||
|
|
||||||
|
|
||||||
|
def test_edited_template_survives_saving_and_produces_real_lines_in_the_mail(
|
||||||
|
tk_root, repository, tmp_path
|
||||||
|
):
|
||||||
|
member = repository.create_member(
|
||||||
|
first_name="Ada", last_name="Lovelace", birth_date="1990-01-01"
|
||||||
|
)
|
||||||
|
member.email = "ada@example.org"
|
||||||
|
member.accepted_at = "2026-08-20"
|
||||||
|
repository.save_member(member)
|
||||||
|
repository.create_manual_claim(
|
||||||
|
member.member_id, title="Aufnahmegebühr", amount="15.00", due_date="2026-09-17"
|
||||||
|
)
|
||||||
|
|
||||||
|
dialog = _open_options(tk_root, repository)
|
||||||
|
try:
|
||||||
|
dialog.mail_template_subject_var.set("Willkommen, {{member.first_name}}")
|
||||||
|
dialog.mail_template_body.delete("1.0", "end")
|
||||||
|
dialog.mail_template_body.insert("1.0", "Offen sind aktuell:\n")
|
||||||
|
dialog.mail_template_placeholders.selection_set(_block_row(dialog))
|
||||||
|
dialog._insert_mail_placeholder()
|
||||||
|
tk_root.update()
|
||||||
|
dialog._save_mail_templates()
|
||||||
|
finally:
|
||||||
|
dialog.grab_release()
|
||||||
|
dialog.destroy()
|
||||||
|
|
||||||
|
generated = generate_and_send_welcome_mail(
|
||||||
|
repository,
|
||||||
|
member.member_id,
|
||||||
|
delivery_mode="local",
|
||||||
|
output_path=tmp_path / "Willkommen.eml",
|
||||||
|
sender_name="Verwaltung C3MA",
|
||||||
|
sender_email="verwaltung@example.org",
|
||||||
|
signature="Der Vorstand",
|
||||||
|
)
|
||||||
|
|
||||||
|
content = BytesParser(policy=default).parsebytes(
|
||||||
|
generated.export_path.read_bytes()
|
||||||
|
).get_content()
|
||||||
|
assert "Aufnahmegebühr (fällig 17.09.2026): 15.00 Euro" in content
|
||||||
|
assert "…" not in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_dialog_refuses_to_save_an_unbalanced_block(tk_root, repository):
|
||||||
|
stored = repository.mail_templates_root / "willkommen.txt"
|
||||||
|
before = stored.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
dialog = _open_options(tk_root, repository)
|
||||||
|
try:
|
||||||
|
dialog.mail_template_body.delete("1.0", "end")
|
||||||
|
dialog.mail_template_body.insert("1.0", "Offen sind aktuell:\n{{#claims}}\n{{claim.title}}")
|
||||||
|
with pytest.raises(RepositoryError, match="wird nicht geschlossen"):
|
||||||
|
dialog._save_mail_templates()
|
||||||
|
finally:
|
||||||
|
dialog.grab_release()
|
||||||
|
dialog.destroy()
|
||||||
|
|
||||||
|
assert stored.read_text(encoding="utf-8") == before
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_broken_template_does_not_half_save_the_others(tk_root, repository):
|
||||||
|
reminder_file = repository.mail_templates_root / "mahnung.txt"
|
||||||
|
before = reminder_file.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
dialog = _open_options(tk_root, repository)
|
||||||
|
try:
|
||||||
|
# Edit a valid template first, then break the second one.
|
||||||
|
dialog.mail_template_var.set("Mahnung")
|
||||||
|
dialog._select_mail_template()
|
||||||
|
dialog.mail_template_body.insert("end", "\nP.S. Bitte Mitgliedsnummer angeben.")
|
||||||
|
dialog.mail_template_var.set("Willkommen & Erstrechnung")
|
||||||
|
dialog._select_mail_template()
|
||||||
|
dialog.mail_template_body.insert("end", "\n{{#claims}}\n{{claim.title}}")
|
||||||
|
with pytest.raises(RepositoryError, match="wird nicht geschlossen"):
|
||||||
|
dialog._save_mail_templates()
|
||||||
|
finally:
|
||||||
|
dialog.grab_release()
|
||||||
|
dialog.destroy()
|
||||||
|
|
||||||
|
assert reminder_file.read_text(encoding="utf-8") == before
|
||||||
@@ -169,3 +169,20 @@ def test_startup_sequence_completes_without_the_housekeeper(read_only_store):
|
|||||||
assert [item.member_id for item in members] == [member.member_id]
|
assert [item.member_id for item in members] == [member.member_id]
|
||||||
assert findings == []
|
assert findings == []
|
||||||
assert repository.member_count() == 1
|
assert repository.member_count() == 1
|
||||||
|
|
||||||
|
|
||||||
|
def test_mail_templates_fall_back_to_the_shipped_defaults(read_only_store):
|
||||||
|
"""A store that predates mail templates has no templates/mail/ directory, and a
|
||||||
|
read-only one cannot get it -- the shipped default has to carry the session."""
|
||||||
|
from ccma.domain.mail_templates import default_mail_template
|
||||||
|
|
||||||
|
repository, _member = read_only_store
|
||||||
|
_set_writable(repository.root, True)
|
||||||
|
for path in repository.mail_templates_root.glob("*.txt"):
|
||||||
|
path.unlink()
|
||||||
|
_set_writable(repository.root, False)
|
||||||
|
|
||||||
|
assert repository.get_mail_template("welcome") == default_mail_template("welcome")
|
||||||
|
assert not list(repository.mail_templates_root.glob("*.txt")), "nothing was written"
|
||||||
|
with pytest.raises(ReadOnlyStoreError, match="schreibgeschützt"):
|
||||||
|
repository.save_mail_template("welcome", subject="Moin", body="Kurz.")
|
||||||
|
|||||||
+3
-3
@@ -178,11 +178,11 @@ def test_debit_mail_is_thunderbird_draft(tmp_path):
|
|||||||
debit = pending_direct_debits(repository, due_until=date(2026, 12, 31))[0][0]
|
debit = pending_direct_debits(repository, due_until=date(2026, 12, 31))[0][0]
|
||||||
|
|
||||||
content = debit_mail_bytes(
|
content = debit_mail_bytes(
|
||||||
recipient=member.email,
|
repository,
|
||||||
first_name=member.first_name,
|
member=member,
|
||||||
debit=debit,
|
debit=debit,
|
||||||
collection_date=date(2026, 8, 3),
|
collection_date=date(2026, 8, 3),
|
||||||
creditor_id="DE98ZZZ09999999999",
|
organization={"name": "C3MA", "creditor_id": "DE98ZZZ09999999999"},
|
||||||
sender_name="Verwaltung C3MA",
|
sender_name="Verwaltung C3MA",
|
||||||
sender_email="verwaltung@example.org",
|
sender_email="verwaltung@example.org",
|
||||||
signature="Der Vorstand",
|
signature="Der Vorstand",
|
||||||
|
|||||||
@@ -0,0 +1,295 @@
|
|||||||
|
from contextlib import contextmanager
|
||||||
|
from email.parser import BytesParser
|
||||||
|
from email.policy import default
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from ccma.services.welcome_mail import generate_and_send_welcome_mail, open_claims
|
||||||
|
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||||
|
|
||||||
|
|
||||||
|
def _new_member_repository(tmp_path):
|
||||||
|
repository = MemberRepository(tmp_path)
|
||||||
|
repository.initialize()
|
||||||
|
organization = repository.get_configuration()["organization"]
|
||||||
|
organization.update(
|
||||||
|
{
|
||||||
|
"name": "Chaos Computer Club Mannheim e.V.",
|
||||||
|
"email": "verwaltung@example.org",
|
||||||
|
"iban": "DE98670505050038907751",
|
||||||
|
"bic": "MANSDE66XXX",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
repository.save_organization(organization)
|
||||||
|
member = repository.create_member(
|
||||||
|
first_name="Ada", last_name="Lovelace", birth_date="1990-01-01"
|
||||||
|
)
|
||||||
|
member.email = "ada@example.org"
|
||||||
|
member.accepted_at = "2026-08-20"
|
||||||
|
repository.save_member(member)
|
||||||
|
repository.create_manual_claim(
|
||||||
|
member.member_id, title="Aufnahmegebühr", amount="15.00", due_date="2026-09-17"
|
||||||
|
)
|
||||||
|
repository.create_manual_claim(
|
||||||
|
member.member_id,
|
||||||
|
title="Mitgliedsbeitrag 2. Halbjahr 2026",
|
||||||
|
amount="60.00",
|
||||||
|
due_date="2026-09-17",
|
||||||
|
)
|
||||||
|
return repository, member
|
||||||
|
|
||||||
|
|
||||||
|
def test_welcome_mail_lists_open_claims_and_bank_details(tmp_path):
|
||||||
|
repository, member = _new_member_repository(tmp_path)
|
||||||
|
export_path = tmp_path / "Willkommen.eml"
|
||||||
|
|
||||||
|
generated = generate_and_send_welcome_mail(
|
||||||
|
repository,
|
||||||
|
member.member_id,
|
||||||
|
delivery_mode="local",
|
||||||
|
output_path=export_path,
|
||||||
|
sender_name="Verwaltung C3MA",
|
||||||
|
sender_email="verwaltung@example.org",
|
||||||
|
signature="Der Vorstand",
|
||||||
|
)
|
||||||
|
|
||||||
|
message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes())
|
||||||
|
content = message.get_content()
|
||||||
|
assert message["To"] == "ada@example.org"
|
||||||
|
assert "Willkommen" in message["Subject"]
|
||||||
|
assert "Aufnahmegebühr (fällig 17.09.2026): 15.00 Euro" in content
|
||||||
|
assert "Mitgliedsbeitrag 2. Halbjahr 2026 (fällig 17.09.2026): 60.00 Euro" in content
|
||||||
|
assert "Gesamtbetrag: 75.00 Euro" in content
|
||||||
|
assert "DE98670505050038907751" in content
|
||||||
|
assert content.rstrip().endswith("Der Vorstand")
|
||||||
|
# No "Mitglied seit" is stored yet -- a member accepted in August starts on 1 September.
|
||||||
|
assert "Ab dem 01.09.2026 bist du offiziell Mitglied" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_welcome_mail_is_archived_and_logged(tmp_path):
|
||||||
|
repository, member = _new_member_repository(tmp_path)
|
||||||
|
|
||||||
|
generated = generate_and_send_welcome_mail(
|
||||||
|
repository,
|
||||||
|
member.member_id,
|
||||||
|
delivery_mode="local",
|
||||||
|
output_path=tmp_path / "Willkommen.eml",
|
||||||
|
sender_name="Verwaltung C3MA",
|
||||||
|
sender_email="verwaltung@example.org",
|
||||||
|
signature="Der Vorstand",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert generated.archive_path.parent.name == "Willkommen"
|
||||||
|
assert generated.archive_path.read_bytes() == generated.export_path.read_bytes()
|
||||||
|
event = repository.get_events(member.member_id)[-1]
|
||||||
|
assert event.event_type == "welcome_email_sent"
|
||||||
|
assert event.data["recipient"] == "ada@example.org"
|
||||||
|
assert len(event.data["claim_ids"]) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_selected_claims_are_billed(tmp_path):
|
||||||
|
repository, member = _new_member_repository(tmp_path)
|
||||||
|
admission = next(
|
||||||
|
claim for claim in open_claims(repository, member.member_id)
|
||||||
|
if claim["title"] == "Aufnahmegebühr"
|
||||||
|
)
|
||||||
|
|
||||||
|
generated = generate_and_send_welcome_mail(
|
||||||
|
repository,
|
||||||
|
member.member_id,
|
||||||
|
claim_ids=[admission["claim_id"]],
|
||||||
|
delivery_mode="local",
|
||||||
|
output_path=tmp_path / "Willkommen.eml",
|
||||||
|
sender_name="Verwaltung C3MA",
|
||||||
|
sender_email="verwaltung@example.org",
|
||||||
|
signature="Der Vorstand",
|
||||||
|
)
|
||||||
|
|
||||||
|
content = BytesParser(policy=default).parsebytes(
|
||||||
|
generated.export_path.read_bytes()
|
||||||
|
).get_content()
|
||||||
|
assert "Aufnahmegebühr" in content
|
||||||
|
assert "Mitgliedsbeitrag 2. Halbjahr 2026" not in content
|
||||||
|
assert "Gesamtbetrag: 15.00 Euro" in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_members_with_mandate_are_told_the_money_is_collected(tmp_path):
|
||||||
|
repository, member = _new_member_repository(tmp_path)
|
||||||
|
member.account_holder = "Ada Lovelace"
|
||||||
|
member.iban = "DE98670505050038907751"
|
||||||
|
member.mandate_reference = "MANDAT-42"
|
||||||
|
member.mandate_signed_at = "2026-08-20"
|
||||||
|
member.mandate_active = True
|
||||||
|
repository.save_member(member)
|
||||||
|
|
||||||
|
generated = generate_and_send_welcome_mail(
|
||||||
|
repository,
|
||||||
|
member.member_id,
|
||||||
|
delivery_mode="local",
|
||||||
|
output_path=tmp_path / "Willkommen.eml",
|
||||||
|
sender_name="Verwaltung C3MA",
|
||||||
|
sender_email="verwaltung@example.org",
|
||||||
|
signature="Der Vorstand",
|
||||||
|
)
|
||||||
|
|
||||||
|
content = BytesParser(policy=default).parsebytes(
|
||||||
|
generated.export_path.read_bytes()
|
||||||
|
).get_content()
|
||||||
|
assert "SEPA-Lastschriftmandat erteilt (Mandatsreferenz MANDAT-42)" in content
|
||||||
|
assert "Bitte überweise" not in content
|
||||||
|
|
||||||
|
|
||||||
|
def test_edited_template_is_used_for_the_mail(tmp_path):
|
||||||
|
repository, member = _new_member_repository(tmp_path)
|
||||||
|
repository.save_mail_template(
|
||||||
|
"welcome",
|
||||||
|
subject="Servus {{member.first_name}}",
|
||||||
|
body="Du schuldest uns {{claims.total}} Euro.\n{{#claims}}* {{claim.title}}{{/claims}}",
|
||||||
|
)
|
||||||
|
|
||||||
|
generated = generate_and_send_welcome_mail(
|
||||||
|
repository,
|
||||||
|
member.member_id,
|
||||||
|
delivery_mode="local",
|
||||||
|
output_path=tmp_path / "Willkommen.eml",
|
||||||
|
sender_name="Verwaltung C3MA",
|
||||||
|
sender_email="verwaltung@example.org",
|
||||||
|
signature="Der Vorstand",
|
||||||
|
)
|
||||||
|
|
||||||
|
message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes())
|
||||||
|
assert message["Subject"] == "Servus Ada"
|
||||||
|
assert message.get_content().replace("\r\n", "\n").strip() == (
|
||||||
|
"Du schuldest uns 75.00 Euro.\n* Aufnahmegebühr\n* Mitgliedsbeitrag 2. Halbjahr 2026"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_mode_delivers_via_smtp_without_local_file(tmp_path, monkeypatch):
|
||||||
|
import ccma.services.welcome_mail as welcome_mail_module
|
||||||
|
|
||||||
|
repository, member = _new_member_repository(tmp_path)
|
||||||
|
repository.save_email_settings(
|
||||||
|
delivery_mode="send",
|
||||||
|
smtp_host="smtp.example.org",
|
||||||
|
smtp_port=587,
|
||||||
|
smtp_security="starttls",
|
||||||
|
smtp_username="verwaltung",
|
||||||
|
smtp_password="secret",
|
||||||
|
imap_host="",
|
||||||
|
imap_port=993,
|
||||||
|
imap_security="ssl",
|
||||||
|
imap_username="",
|
||||||
|
imap_password="",
|
||||||
|
imap_drafts_folder="INBOX.Entwürfe",
|
||||||
|
)
|
||||||
|
sent = []
|
||||||
|
monkeypatch.setattr(
|
||||||
|
welcome_mail_module, "smtp_session", contextmanager(lambda settings: iter(["client"]))
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
welcome_mail_module, "send_via_smtp", lambda client, content: sent.append(content)
|
||||||
|
)
|
||||||
|
|
||||||
|
generated = generate_and_send_welcome_mail(
|
||||||
|
repository,
|
||||||
|
member.member_id,
|
||||||
|
delivery_mode="send",
|
||||||
|
sender_name="Verwaltung C3MA",
|
||||||
|
sender_email="verwaltung@example.org",
|
||||||
|
signature="Der Vorstand",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert generated.export_path is None
|
||||||
|
assert generated.archive_path.read_bytes() == sent[0]
|
||||||
|
assert repository.get_events(member.member_id)[-1].data["delivery_mode"] == "send"
|
||||||
|
|
||||||
|
|
||||||
|
def test_member_without_email_is_refused(tmp_path):
|
||||||
|
repository, member = _new_member_repository(tmp_path)
|
||||||
|
member.email = ""
|
||||||
|
repository.save_member(member)
|
||||||
|
|
||||||
|
with pytest.raises(RepositoryError, match="E-Mail-Adresse"):
|
||||||
|
generate_and_send_welcome_mail(
|
||||||
|
repository,
|
||||||
|
member.member_id,
|
||||||
|
delivery_mode="local",
|
||||||
|
output_path=tmp_path / "Willkommen.eml",
|
||||||
|
sender_name="Verwaltung C3MA",
|
||||||
|
sender_email="verwaltung@example.org",
|
||||||
|
signature="Der Vorstand",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_archiving_failure_after_smtp_success_still_logs_the_sent_mail(tmp_path, monkeypatch):
|
||||||
|
import ccma.services.welcome_mail as welcome_mail_module
|
||||||
|
|
||||||
|
repository, member = _new_member_repository(tmp_path)
|
||||||
|
repository.save_email_settings(
|
||||||
|
delivery_mode="send",
|
||||||
|
smtp_host="mail.example.org",
|
||||||
|
smtp_port=587,
|
||||||
|
smtp_security="none",
|
||||||
|
smtp_username="",
|
||||||
|
smtp_password="",
|
||||||
|
imap_host="",
|
||||||
|
imap_port=993,
|
||||||
|
imap_security="ssl",
|
||||||
|
imap_username="",
|
||||||
|
imap_password="",
|
||||||
|
imap_drafts_folder="",
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
welcome_mail_module, "smtp_session", contextmanager(lambda settings: iter(["client"]))
|
||||||
|
)
|
||||||
|
monkeypatch.setattr(welcome_mail_module, "send_via_smtp", lambda client, content: None)
|
||||||
|
real_replace = welcome_mail_module.os.replace
|
||||||
|
|
||||||
|
def _flaky_replace(src, dst):
|
||||||
|
# Only the final move into the member file fails -- the mail is out by then.
|
||||||
|
if str(dst).endswith(".eml") and "Willkommen" in str(dst):
|
||||||
|
raise OSError("disk full")
|
||||||
|
return real_replace(src, dst)
|
||||||
|
|
||||||
|
monkeypatch.setattr(welcome_mail_module.os, "replace", _flaky_replace)
|
||||||
|
|
||||||
|
with pytest.raises(RepositoryError, match="zugestellt"):
|
||||||
|
generate_and_send_welcome_mail(
|
||||||
|
repository,
|
||||||
|
member.member_id,
|
||||||
|
delivery_mode="send",
|
||||||
|
sender_name="Verwaltung C3MA",
|
||||||
|
sender_email="verwaltung@example.org",
|
||||||
|
signature="Der Vorstand",
|
||||||
|
)
|
||||||
|
|
||||||
|
event = repository.get_events(member.member_id)[-1]
|
||||||
|
assert event.event_type == "welcome_email_sent"
|
||||||
|
assert event.data["archive_error"]
|
||||||
|
assert "document" not in event.references
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_template_broken_outside_ccma_stops_the_send(tmp_path):
|
||||||
|
repository, member = _new_member_repository(tmp_path)
|
||||||
|
# The templates are plain files in the store, so they can be edited (and broken)
|
||||||
|
# with any text editor -- the send has to notice instead of mailing the marker.
|
||||||
|
(repository.mail_templates_root / "willkommen.txt").write_text(
|
||||||
|
"Betreff: Willkommen\n\nHallo,\n{{#claims}}\n{{claim.title}}\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
|
||||||
|
with pytest.raises(RepositoryError, match="wird nicht geschlossen"):
|
||||||
|
generate_and_send_welcome_mail(
|
||||||
|
repository,
|
||||||
|
member.member_id,
|
||||||
|
delivery_mode="local",
|
||||||
|
output_path=tmp_path / "Willkommen.eml",
|
||||||
|
sender_name="Verwaltung C3MA",
|
||||||
|
sender_email="verwaltung@example.org",
|
||||||
|
signature="Der Vorstand",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert not (tmp_path / "Willkommen.eml").exists()
|
||||||
|
archive = repository.members_root / member.member_id / "files" / "documents" / "Willkommen"
|
||||||
|
assert not archive.exists()
|
||||||
|
assert repository.get_events(member.member_id)[-1].event_type != "welcome_email_sent"
|
||||||
Reference in New Issue
Block a user