diff --git a/src/ccma/assets/CHANGELOG.json b/src/ccma/assets/CHANGELOG.json index ec90eea..91db129 100644 --- a/src/ccma/assets/CHANGELOG.json +++ b/src/ccma/assets/CHANGELOG.json @@ -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." ] }, { diff --git a/src/ccma/services/welcome_mail.py b/src/ccma/services/welcome_mail.py new file mode 100644 index 0000000..d2fea40 --- /dev/null +++ b/src/ccma/services/welcome_mail.py @@ -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}" diff --git a/src/ccma/ui/member_tab.py b/src/ccma/ui/member_tab.py index f599045..1100f44 100644 --- a/src/ccma/ui/member_tab.py +++ b/src/ccma/ui/member_tab.py @@ -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("", lambda _event: self._open_selected_claim()) self.claims.bind("", 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, diff --git a/src/ccma/ui/welcome_mail_dialog.py b/src/ccma/ui/welcome_mail_dialog.py new file mode 100644 index 0000000..fb8002a --- /dev/null +++ b/src/ccma/ui/welcome_mail_dialog.py @@ -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("<>", 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() diff --git a/tests/test_welcome_mail.py b/tests/test_welcome_mail.py new file mode 100644 index 0000000..1178018 --- /dev/null +++ b/tests/test_welcome_mail.py @@ -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", + )