Add configurable e-mail delivery: direct SMTP send or IMAP drafts

Mahnungs- and SEPA-info-mails could previously only be saved as a local .eml
file that still had to be manually imported into Thunderbird. Add a per-store
"E-Mail-Versand" configuration (Optionen -> E-Mail-Versand, stored in
repository.json alongside the rest of the club's settings, since different
stores may use different mailboxes) with four delivery modes:

- "Lokal speichern": today's behaviour, unchanged default for existing stores.
- "Direkt versenden": sends via SMTP.
- "Als Entwurf ablegen": IMAP APPENDs into a configurable drafts folder, so it
  shows up live in whatever mail client is already watching that account.
- "Jedes Mal fragen": prompts once per generation action (not per e-mail --
  a SEPA batch can cover dozens of members) with Senden/Entwürfe/Abbrechen.

New ccma.services.mail_delivery module (smtplib/imaplib, no new dependency)
opens one authenticated connection per batch and reuses it across all
messages instead of reconnecting per recipient. Both "Verbindung testen"
buttons in Options exercise the same connection path used for real delivery.
The archived per-member copy of every generated e-mail is unaffected and
still always written regardless of delivery mode.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Marcel Peterkau
2026-08-15 04:34:34 +02:00
co-authored by Claude Sonnet 5
parent b7554478ac
commit 6b0da82b45
13 changed files with 1030 additions and 88 deletions
+84 -54
View File
@@ -2,6 +2,7 @@ from __future__ import annotations
import hashlib
import re
from contextlib import nullcontext
from dataclasses import dataclass
from datetime import date, datetime
from email.message import EmailMessage
@@ -9,6 +10,7 @@ from email.policy import SMTP
from email.utils import format_datetime, make_msgid
from pathlib import Path
from ccma.services.mail_delivery import append_to_imap_drafts, imap_session, send_via_smtp, smtp_session
from ccma.services.sepa import DirectDebit
from ccma.storage.repository import MemberRepository, RepositoryError
@@ -17,7 +19,8 @@ from ccma.storage.repository import MemberRepository, RepositoryError
class GeneratedDebitMail:
member_id: str
recipient: str
export_path: Path
# None for "send"/"drafts" delivery -- see reminder_mail.GeneratedReminderMail.
export_path: Path | None
archive_path: Path
@@ -94,67 +97,94 @@ def generate_debit_mails(
debits: list[DirectDebit],
*,
collection_date: date,
output_directory: Path | str,
delivery_mode: str,
output_directory: Path | str | None = None,
sender_name: str,
sender_email: str,
signature: str,
) -> tuple[list[GeneratedDebitMail], list[str]]:
output = Path(output_directory)
output.mkdir(parents=True, exist_ok=True)
output: Path | None = None
email_settings: dict | None = None
if delivery_mode == "local":
if not output_directory:
raise RepositoryError("Kein Zielordner für die lokale Ablage angegeben.")
output = Path(output_directory)
output.mkdir(parents=True, exist_ok=True)
session = nullcontext(None)
elif delivery_mode == "send":
email_settings = repository.get_email_settings()
session = smtp_session(email_settings)
elif delivery_mode == "drafts":
email_settings = repository.get_email_settings()
session = imap_session(email_settings)
else:
raise RepositoryError(f"Unbekannter Versandmodus: {delivery_mode}")
organization = repository.get_configuration().get("organization") or {}
creditor_id = str(organization.get("creditor_id", "")).strip()
if not creditor_id:
raise RepositoryError("In den Optionen fehlt die Gläubiger-ID.")
generated: list[GeneratedDebitMail] = []
warnings: list[str] = []
for debit in debits:
member = repository.get_member(debit.member_id)
if not member.email.strip():
warnings.append(f"{member.member_number or member.display_name}: E-Mail-Adresse fehlt.")
continue
content = debit_mail_bytes(
recipient=member.email,
first_name=member.first_name,
debit=debit,
collection_date=collection_date,
creditor_id=creditor_id,
sender_name=sender_name,
sender_email=sender_email,
signature=signature,
)
filename = (
f"SEPA-Info-{collection_date.isoformat()}-"
f"{_safe_filename(member.member_number or member.display_name)}.eml"
)
export_path = _available_path(output, filename)
archive_dir = repository.members_root / member.member_id / "files" / "documents" / "SEPA"
archive_dir.mkdir(parents=True, exist_ok=True)
archive_path = _available_path(archive_dir, filename)
export_path.write_bytes(content)
try:
archive_path.write_bytes(content)
except OSError:
export_path.unlink(missing_ok=True)
raise
relative_path = archive_path.relative_to(
repository.members_root / member.member_id / "files"
).as_posix()
digest = hashlib.sha256(content).hexdigest()
repository.append_event(
member.member_id,
event_type="sepa_notification_generated",
summary=f"SEPA-Info-Mail erzeugt: {archive_path.name}",
actor_type="user",
actor_name="Vorstand",
references={"document": relative_path},
data={
"sha256": digest,
"recipient": member.email,
"collection_date": collection_date.isoformat(),
"amount": f"{debit.amount:.2f}",
},
)
generated.append(
GeneratedDebitMail(member.member_id, member.email, export_path, archive_path)
)
# One connection is opened for the whole batch (a run can cover dozens of
# members) instead of reconnecting/re-authenticating per recipient.
with session as client:
for debit in debits:
member = repository.get_member(debit.member_id)
if not member.email.strip():
warnings.append(f"{member.member_number or member.display_name}: E-Mail-Adresse fehlt.")
continue
content = debit_mail_bytes(
recipient=member.email,
first_name=member.first_name,
debit=debit,
collection_date=collection_date,
creditor_id=creditor_id,
sender_name=sender_name,
sender_email=sender_email,
signature=signature,
)
filename = (
f"SEPA-Info-{collection_date.isoformat()}-"
f"{_safe_filename(member.member_number or member.display_name)}.eml"
)
archive_dir = repository.members_root / member.member_id / "files" / "documents" / "SEPA"
archive_dir.mkdir(parents=True, exist_ok=True)
archive_path = _available_path(archive_dir, filename)
export_path: Path | None = None
if delivery_mode == "local":
export_path = _available_path(output, filename)
export_path.write_bytes(content)
elif delivery_mode == "send":
send_via_smtp(client, content)
else:
append_to_imap_drafts(client, content, folder=email_settings["imap_drafts_folder"])
try:
archive_path.write_bytes(content)
except OSError:
if export_path is not None:
export_path.unlink(missing_ok=True)
raise
relative_path = archive_path.relative_to(
repository.members_root / member.member_id / "files"
).as_posix()
digest = hashlib.sha256(content).hexdigest()
repository.append_event(
member.member_id,
event_type="sepa_notification_generated",
summary=f"SEPA-Info-Mail erzeugt: {archive_path.name}",
actor_type="user",
actor_name="Vorstand",
references={"document": relative_path},
data={
"sha256": digest,
"recipient": member.email,
"collection_date": collection_date.isoformat(),
"amount": f"{debit.amount:.2f}",
"delivery_mode": delivery_mode,
},
)
generated.append(
GeneratedDebitMail(member.member_id, member.email, export_path, archive_path)
)
return generated, warnings