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
+110
View File
@@ -0,0 +1,110 @@
from __future__ import annotations
import imaplib
import smtplib
import ssl
from collections.abc import Iterator
from contextlib import contextmanager, suppress
from email.parser import BytesParser
from email.policy import default as email_default_policy
from ccma.storage.repository import RepositoryError
def _ssl_context() -> ssl.SSLContext:
return ssl.create_default_context()
def _smtp_client(settings: dict) -> smtplib.SMTP:
host = str(settings.get("smtp_host", "")).strip()
if not host:
raise RepositoryError("Kein SMTP-Server konfiguriert.")
port = int(settings.get("smtp_port", 587))
security = str(settings.get("smtp_security", "starttls"))
client = (
smtplib.SMTP_SSL(host, port, context=_ssl_context(), timeout=30)
if security == "ssl"
else smtplib.SMTP(host, port, timeout=30)
)
if security == "starttls":
client.starttls(context=_ssl_context())
username = str(settings.get("smtp_username", "")).strip()
if username:
client.login(username, str(settings.get("smtp_password", "")))
return client
def _imap_client(settings: dict) -> imaplib.IMAP4:
host = str(settings.get("imap_host", "")).strip()
if not host:
raise RepositoryError("Kein IMAP-Server konfiguriert.")
port = int(settings.get("imap_port", 993))
security = str(settings.get("imap_security", "ssl"))
client = (
imaplib.IMAP4_SSL(host, port, timeout=30)
if security == "ssl"
else imaplib.IMAP4(host, port, timeout=30)
)
if security == "starttls":
client.starttls(_ssl_context())
username = str(settings.get("imap_username", "")).strip()
if username:
client.login(username, str(settings.get("imap_password", "")))
return client
@contextmanager
def smtp_session(settings: dict) -> Iterator[smtplib.SMTP]:
"""Opens one authenticated SMTP connection to reuse across several sends (a SEPA
info-mail batch may cover dozens of members -- reconnecting/re-authenticating per
message would be slow and can trigger provider rate limits)."""
try:
client = _smtp_client(settings)
except (OSError, smtplib.SMTPException) as exc:
raise RepositoryError(f"SMTP-Verbindung fehlgeschlagen: {exc}") from exc
try:
yield client
finally:
with suppress(OSError, smtplib.SMTPException):
client.quit()
@contextmanager
def imap_session(settings: dict) -> Iterator[imaplib.IMAP4]:
try:
client = _imap_client(settings)
except (OSError, imaplib.IMAP4.error) as exc:
raise RepositoryError(f"IMAP-Verbindung fehlgeschlagen: {exc}") from exc
try:
yield client
finally:
with suppress(OSError, imaplib.IMAP4.error):
client.logout()
def send_via_smtp(client: smtplib.SMTP, message_bytes: bytes) -> None:
message = BytesParser(policy=email_default_policy).parsebytes(message_bytes)
try:
client.send_message(message)
except (OSError, smtplib.SMTPException) as exc:
raise RepositoryError(f"E-Mail konnte nicht versandt werden: {exc}") from exc
def append_to_imap_drafts(client: imaplib.IMAP4, message_bytes: bytes, *, folder: str) -> None:
try:
status, response = client.append(folder, r"(\Draft)", None, message_bytes)
except (OSError, imaplib.IMAP4.error) as exc:
raise RepositoryError(f"E-Mail konnte nicht als Entwurf abgelegt werden: {exc}") from exc
if status != "OK":
detail = response[0].decode("utf-8", "replace") if response and response[0] else status
raise RepositoryError(f"IMAP-Server hat die Ablage im Ordner „{folder}“ abgelehnt: {detail}")
def test_smtp_connection(settings: dict) -> None:
with smtp_session(settings):
pass
def test_imap_connection(settings: dict) -> None:
with imap_session(settings):
pass