mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-24 22:45:18 +02:00
feat: add reminder email drafts
This commit is contained in:
@@ -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
@@ -5,7 +5,7 @@ from collections.abc import Callable
|
||||
from datetime import date
|
||||
from decimal import Decimal
|
||||
from pathlib import Path
|
||||
from tkinter import messagebox, ttk
|
||||
from tkinter import filedialog, messagebox, ttk
|
||||
|
||||
from ccma.domain.contributions import (
|
||||
CLAIM_STATUS_LABELS,
|
||||
@@ -21,6 +21,7 @@ from ccma.domain.contributions import (
|
||||
payment_allocated_total,
|
||||
)
|
||||
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.ui.document_dialog import DocumentTemplateDialog
|
||||
from ccma.ui.labels import (
|
||||
@@ -156,6 +157,13 @@ class ClaimTab(ttk.Frame):
|
||||
buttons, text="Als versandt markieren", command=self._send_reminder, state="disabled"
|
||||
)
|
||||
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="Dokument erzeugen", command=self._create_document).pack(
|
||||
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"})
|
||||
state = "normal" if editable else "disabled"
|
||||
self.send_reminder_button.configure(state=state)
|
||||
self.email_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:
|
||||
reminder = self._selected_reminder()
|
||||
if not reminder or not messagebox.askyesno(
|
||||
@@ -893,6 +915,97 @@ class ReminderDialog(_Dialog):
|
||||
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):
|
||||
def __init__(self, master, repository, member_id, claim_id, on_saved):
|
||||
super().__init__(master, "Mahnsperre setzen", on_saved)
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user