feat: add reminder email drafts

This commit is contained in:
Marcel Peterkau
2026-07-30 01:17:33 +02:00
parent 3d3b845d9f
commit fc4eacb591
3 changed files with 377 additions and 1 deletions
+180
View File
@@ -0,0 +1,180 @@
from __future__ import annotations
import hashlib
import os
import re
from dataclasses import dataclass
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 ccma.domain.contributions import claim_balance
from ccma.domain.dates import format_date_for_display
from ccma.storage.repository import MemberRepository, RepositoryError
@dataclass(frozen=True, slots=True)
class GeneratedReminderMail:
export_path: Path
archive_path: Path
sha256: str
def _safe_filename(value: str) -> str:
cleaned = re.sub(r"[^A-Za-z0-9._-]+", "-", value.strip()).strip("-._")
return cleaned or "Mahnung"
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}"
def reminder_mail_bytes(
*,
recipient: str,
first_name: str,
reminder: dict,
claim: dict,
balance: str,
organization: dict,
sender_name: str,
sender_email: str,
signature: 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 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()
message.set_content(
"\n".join(
(
f"Hallo {first_name.strip() or 'Mitglied'},",
"",
f"zu der Forderung „{claim.get('title', 'Offene Forderung')}"
"ist noch ein Betrag offen.",
"",
f"Offener Betrag: {balance} Euro",
f"Ursprünglich fällig: "
f"{format_date_for_display(str(claim.get('due_date', '')))}",
f"Neue Zahlungsfrist: "
f"{format_date_for_display(str(reminder.get('payment_deadline', '')))}",
f"Mahngebühr: {reminder.get('fee', '0.00')} Euro",
f"Hinweis: {detail}" if detail else "",
"",
"Bitte überweise den offenen Betrag unter Angabe deiner Mitgliedsnummer an:",
payment_details,
"",
"Sollte sich deine Zahlung mit dieser Nachricht überschnitten haben, "
"betrachte sie bitte als gegenstandslos.",
"",
"Viele Grüße",
signature.strip(),
)
),
charset="utf-8",
)
return message.as_bytes()
def generate_and_send_reminder_mail(
repository: MemberRepository,
member_id: str,
claim_id: str,
reminder_id: str,
*,
output_path: Path | str,
sender_name: str,
sender_email: str,
signature: str,
) -> GeneratedReminderMail:
member = repository.get_member(member_id)
data, claim = repository.get_claim(member_id, claim_id)
reminder = repository._find_reminder(data, claim_id, reminder_id)
if str(reminder.get("status", "")) not in {"draft", "generated"}:
raise RepositoryError("Nur ein Mahnungsentwurf kann als E-Mail erzeugt werden.")
if not member.email.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 die Mahnung ist eine gültige Absenderadresse erforderlich.")
target = Path(output_path)
target.parent.mkdir(parents=True, exist_ok=True)
if target.exists():
raise RepositoryError(f"Die Ausgabedatei existiert bereits: {target}")
sent_reminder = repository.mark_reminder_sent(member_id, claim_id, reminder_id)
updated_data, updated_claim = repository.get_claim(member_id, claim_id)
organization = repository.get_configuration().get("organization") or {}
content = reminder_mail_bytes(
recipient=member.email,
first_name=member.first_name,
reminder=sent_reminder,
claim=updated_claim,
balance=f"{claim_balance(updated_data, updated_claim):.2f}",
organization=organization,
sender_name=sender_name,
sender_email=sender_email,
signature=signature,
)
archive_dir = repository.members_root / member_id / "files" / "documents" / "Mahnungen"
archive_dir.mkdir(parents=True, exist_ok=True)
filename = (
f"{datetime.now().date().isoformat()}-"
f"{_safe_filename(str(sent_reminder.get('name', 'Mahnung')))}.eml"
)
archive_path = _available_path(archive_dir, filename)
export_temp = target.with_name(f".{target.name}.tmp")
archive_temp = archive_path.with_name(f".{archive_path.name}.tmp")
try:
export_temp.write_bytes(content)
archive_temp.write_bytes(content)
os.replace(export_temp, target)
os.replace(archive_temp, archive_path)
finally:
export_temp.unlink(missing_ok=True)
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="reminder_email_sent",
summary=f"Mahnung per E-Mail versandt: {sent_reminder.get('name', '')}",
actor_type="user",
actor_name="Vorstand",
references={
"claim_id": claim_id,
"reminder_id": reminder_id,
"document": relative_path,
},
data={
"recipient": member.email,
"sha256": digest,
"payment_deadline": str(sent_reminder.get("payment_deadline", "")),
},
)
return GeneratedReminderMail(target, archive_path, digest)
+114 -1
View File
@@ -5,7 +5,7 @@ from collections.abc import Callable
from datetime import date from datetime import date
from decimal import Decimal from decimal import Decimal
from pathlib import Path from pathlib import Path
from tkinter import messagebox, ttk from tkinter import filedialog, messagebox, ttk
from ccma.domain.contributions import ( from ccma.domain.contributions import (
CLAIM_STATUS_LABELS, CLAIM_STATUS_LABELS,
@@ -21,6 +21,7 @@ from ccma.domain.contributions import (
payment_allocated_total, payment_allocated_total,
) )
from ccma.domain.dates import date_input_hint, format_date_for_display from ccma.domain.dates import date_input_hint, format_date_for_display
from ccma.services.reminder_mail import generate_and_send_reminder_mail
from ccma.storage.repository import MemberRepository, RepositoryError from ccma.storage.repository import MemberRepository, RepositoryError
from ccma.ui.document_dialog import DocumentTemplateDialog from ccma.ui.document_dialog import DocumentTemplateDialog
from ccma.ui.labels import ( from ccma.ui.labels import (
@@ -156,6 +157,13 @@ class ClaimTab(ttk.Frame):
buttons, text="Als versandt markieren", command=self._send_reminder, state="disabled" buttons, text="Als versandt markieren", command=self._send_reminder, state="disabled"
) )
self.send_reminder_button.pack(side="right", padx=(8, 0)) self.send_reminder_button.pack(side="right", padx=(8, 0))
self.email_reminder_button = ttk.Button(
buttons,
text="E-Mail erzeugen & versandt",
command=self._email_reminder,
state="disabled",
)
self.email_reminder_button.pack(side="right", padx=(8, 0))
ttk.Button(buttons, text="Mahnung vorbereiten", command=self._add_reminder).pack(side="right") ttk.Button(buttons, text="Mahnung vorbereiten", command=self._add_reminder).pack(side="right")
ttk.Button(buttons, text="Dokument erzeugen", command=self._create_document).pack( ttk.Button(buttons, text="Dokument erzeugen", command=self._create_document).pack(
side="right", padx=(0, 8) side="right", padx=(0, 8)
@@ -425,8 +433,22 @@ class ClaimTab(ttk.Frame):
editable = bool(reminder and str(reminder.get("status", "draft")) in {"draft", "generated"}) editable = bool(reminder and str(reminder.get("status", "draft")) in {"draft", "generated"})
state = "normal" if editable else "disabled" state = "normal" if editable else "disabled"
self.send_reminder_button.configure(state=state) self.send_reminder_button.configure(state=state)
self.email_reminder_button.configure(state=state)
self.discard_reminder_button.configure(state=state) self.discard_reminder_button.configure(state=state)
def _email_reminder(self) -> None:
reminder = self._selected_reminder()
if reminder:
ReminderMailDialog(
self,
self.repository,
self.member_id,
self.claim_id,
str(reminder["reminder_id"]),
str(reminder.get("name", "Mahnung")),
self._changed,
)
def _send_reminder(self) -> None: def _send_reminder(self) -> None:
reminder = self._selected_reminder() reminder = self._selected_reminder()
if not reminder or not messagebox.askyesno( if not reminder or not messagebox.askyesno(
@@ -893,6 +915,97 @@ class ReminderDialog(_Dialog):
self.on_saved() self.on_saved()
class ReminderMailDialog(tk.Toplevel):
def __init__(
self,
master,
repository,
member_id,
claim_id,
reminder_id,
reminder_name,
on_saved,
):
super().__init__(master)
self.repository = repository
self.member_id = member_id
self.claim_id = claim_id
self.reminder_id = reminder_id
self.reminder_name = reminder_name
self.on_saved = on_saved
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.title("Mahnung als E-Mail erzeugen")
self.transient(master)
self.resizable(True, False)
self.columnconfigure(1, weight=1)
ttk.Label(
self,
text=(
"Die erzeugte E-Mail wird in der Mitgliederakte archiviert und die Mahnung "
"sofort als versandt gebucht. Zahlungsfrist und Mahngebühr werden dabei wirksam."
),
wraplength=620,
).grid(row=0, column=0, columnspan=2, sticky="w", padx=14, pady=(14, 10))
ttk.Label(self, text="Absendername").grid(row=1, column=0, sticky="w", padx=14, pady=5)
ttk.Entry(self, textvariable=self.sender_name_var, width=55).grid(
row=1, column=1, sticky="ew", padx=(0, 14), pady=5
)
ttk.Label(self, text="Absenderadresse").grid(row=2, column=0, sticky="w", padx=14, pady=5)
ttk.Entry(self, textvariable=self.sender_email_var, width=55).grid(
row=2, column=1, sticky="ew", padx=(0, 14), pady=5
)
ttk.Label(self, text="Signatur").grid(row=3, column=0, sticky="nw", padx=14, pady=5)
self.signature = tk.Text(self, width=55, height=5, wrap="word")
self.signature.grid(row=3, column=1, sticky="ew", padx=(0, 14), pady=5)
self.signature.insert("1.0", "Der Vorstand\nChaos Computer Club Mannheim e.V.")
buttons = ttk.Frame(self, padding=14)
buttons.grid(row=4, column=0, columnspan=2, sticky="e")
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left")
ttk.Button(
buttons,
text="EML speichern und als versandt buchen",
style="Accent.TButton",
command=self._generate,
).pack(side="left", padx=(8, 0))
self.grab_set()
def _generate(self) -> None:
selected = filedialog.asksaveasfilename(
parent=self,
title="Mahnung als Thunderbird-Entwurf speichern",
defaultextension=".eml",
initialfile=f"{self.reminder_name}.eml",
filetypes=[("E-Mail-Datei", "*.eml"), ("Alle Dateien", "*.*")],
)
if not selected:
return
try:
result = generate_and_send_reminder_mail(
self.repository,
self.member_id,
self.claim_id,
self.reminder_id,
output_path=selected,
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("Mahnung konnte nicht erzeugt werden", str(exc), parent=self)
return
messagebox.showinfo(
"Mahnung erzeugt",
f"Die E-Mail wurde gespeichert und als versandt gebucht:\n{result.export_path}",
parent=self,
)
self.destroy()
self.on_saved()
class DunningHoldDialog(_Dialog): class DunningHoldDialog(_Dialog):
def __init__(self, master, repository, member_id, claim_id, on_saved): def __init__(self, master, repository, member_id, claim_id, on_saved):
super().__init__(master, "Mahnsperre setzen", on_saved) super().__init__(master, "Mahnsperre setzen", on_saved)
+83
View File
@@ -0,0 +1,83 @@
from email.parser import BytesParser
from email.policy import default
from test_reminders import _overdue_claim_repository
from ccma.services.reminder_mail import generate_and_send_reminder_mail
def test_reminder_mail_is_archived_and_marks_reminder_sent(tmp_path):
repository, member = _overdue_claim_repository(tmp_path / "store")
member.email = "reminder@example.org"
repository.save_member(member)
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)
reminder = repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Zahlungserinnerung",
payment_deadline_days=14,
detail="Bitte Mitgliedsnummer angeben.",
)
export_path = tmp_path / "Zahlungserinnerung.eml"
generated = generate_and_send_reminder_mail(
repository,
member.member_id,
"claim-1",
reminder["reminder_id"],
output_path=export_path,
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
assert generated.export_path.read_bytes() == generated.archive_path.read_bytes()
message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes())
assert message["To"] == "reminder@example.org"
assert "Zahlungserinnerung" in message["Subject"]
assert "100.00 Euro" in message.get_content()
assert "DE98670505050038907751" in message.get_content()
data = repository.get_contributions(member.member_id)
assert data.reminders[0]["status"] == "sent"
assert data.reminders[0]["payment_deadline"]
assert repository.get_events(member.member_id)[-1].event_type == "reminder_email_sent"
assert generated.archive_path.parent.name == "Mahnungen"
def test_reminder_mail_includes_fee_in_open_balance(tmp_path):
repository, member = _overdue_claim_repository(tmp_path / "store")
member.email = "reminder@example.org"
repository.save_member(member)
reminder = repository.create_reminder_draft(
member.member_id,
"claim-1",
level=1,
name="Erste Mahnung",
payment_deadline_days=14,
fee="5.00",
)
generated = generate_and_send_reminder_mail(
repository,
member.member_id,
"claim-1",
reminder["reminder_id"],
output_path=tmp_path / "Mahnung.eml",
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
signature="Der Vorstand",
)
message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes())
assert "Offener Betrag: 105.00 Euro" in message.get_content()
assert "Mahngebühr: 5.00 Euro" in message.get_content()