Send new members a welcome mail that doubles as their first invoice

Accepting a member left the board writing the "welcome, you are a member from
the 1st, and this is what it costs" mail by hand. The member file now offers it
directly: it greets the member, names the membership number and the start of the
membership, and lists every claim that is still open -- admission fee and first
contribution -- with a payment hint that adapts to whether a direct debit
mandate is active.

"Mitglied seit" rejects future dates, so a member accepted this month cannot
carry their actual start date yet; {{membership.start_date}} therefore falls
back to the 1st of the month following the acceptance decision. Individual
claims can be deselected before sending, the finished mail can be previewed,
and the result is archived in the member file like every other generated mail.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Marcel Peterkau
2026-08-28 20:49:08 +02:00
co-authored by Claude Opus 5
parent d6cd58a788
commit aa0c16347b
5 changed files with 722 additions and 1 deletions
+2 -1
View File
@@ -39,7 +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.",
"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.",
"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."
"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."
]
},
{
+242
View File
@@ -0,0 +1,242 @@
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,
)
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")
export_path: Path | None = None
try:
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
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)
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)"
)
os.replace(archive_temp, archive_path)
finally:
archive_temp.unlink(missing_ok=True)
relative_path = archive_path.relative_to(
repository.members_root / member_id / "files"
).as_posix()
digest = hashlib.sha256(content).hexdigest()
repository.append_event(
member_id,
event_type="welcome_email_sent",
summary="Willkommens- und Erstrechnungsmail erzeugt",
actor_type="user",
actor_name="Vorstand",
references={"document": relative_path},
data={
"recipient": member.email,
"sha256": digest,
"claim_ids": [str(claim.get("claim_id", "")) for claim in claims],
"delivery_mode": delivery_mode,
},
)
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}"
+16
View File
@@ -38,6 +38,7 @@ from ccma.ui.labels import display_label, storage_key
from ccma.ui.messages import MessageAction, MessageBannerList, TabMessage
from ccma.ui.payment_dialog import PaymentCreateDialog, PaymentEditDialog
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-
# 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.bind("<Double-1>", 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.rowconfigure(1, weight=1)
@@ -1177,6 +1185,14 @@ class MemberTab(ttk.Frame):
except OSError as exc:
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:
DocumentTemplateDialog(
self,
+241
View File
@@ -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()
+221
View File
@@ -0,0 +1,221 @@
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",
)