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
+101
View File
@@ -138,6 +138,23 @@ DEFAULT_CONFIGURATION = {
],
},
"contribution_rules": DEFAULT_CONTRIBUTION_RULES,
# "local" reproduces the historical behaviour (always ask for a save location,
# never touch the network) so repositories that never configured this stay on
# exactly what they had before -- it is a first-class choice, not just a fallback.
"email_settings": {
"delivery_mode": "local",
"smtp_host": "",
"smtp_port": 587,
"smtp_security": "starttls",
"smtp_username": "",
"smtp_password": "",
"imap_host": "",
"imap_port": 993,
"imap_security": "ssl",
"imap_username": "",
"imap_password": "",
"imap_drafts_folder": "INBOX.Entwürfe",
},
}
@@ -2607,6 +2624,90 @@ class MemberRepository:
}
write_json_atomic(self.root / "repository.json", config)
def get_email_settings(self) -> dict:
config = self.get_configuration()
settings = config.get("email_settings") or {}
defaults = DEFAULT_CONFIGURATION["email_settings"]
delivery_mode = str(settings.get("delivery_mode", defaults["delivery_mode"]))
if delivery_mode not in {"local", "send", "drafts", "ask"}:
delivery_mode = defaults["delivery_mode"]
smtp_security = str(settings.get("smtp_security", defaults["smtp_security"]))
if smtp_security not in {"starttls", "ssl", "none"}:
smtp_security = defaults["smtp_security"]
imap_security = str(settings.get("imap_security", defaults["imap_security"]))
if imap_security not in {"starttls", "ssl", "none"}:
imap_security = defaults["imap_security"]
try:
smtp_port = int(settings.get("smtp_port", defaults["smtp_port"]))
except (TypeError, ValueError):
smtp_port = defaults["smtp_port"]
try:
imap_port = int(settings.get("imap_port", defaults["imap_port"]))
except (TypeError, ValueError):
imap_port = defaults["imap_port"]
return {
"delivery_mode": delivery_mode,
"smtp_host": str(settings.get("smtp_host", "")),
"smtp_port": smtp_port,
"smtp_security": smtp_security,
"smtp_username": str(settings.get("smtp_username", "")),
"smtp_password": str(settings.get("smtp_password", "")),
"imap_host": str(settings.get("imap_host", "")),
"imap_port": imap_port,
"imap_security": imap_security,
"imap_username": str(settings.get("imap_username", "")),
"imap_password": str(settings.get("imap_password", "")),
"imap_drafts_folder": str(
settings.get("imap_drafts_folder", defaults["imap_drafts_folder"])
),
}
def save_email_settings(
self,
*,
delivery_mode: str,
smtp_host: str,
smtp_port: int,
smtp_security: str,
smtp_username: str,
smtp_password: str,
imap_host: str,
imap_port: int,
imap_security: str,
imap_username: str,
imap_password: str,
imap_drafts_folder: str,
) -> None:
if delivery_mode not in {"local", "send", "drafts", "ask"}:
raise RepositoryError("Ungültiger Versandmodus.")
if smtp_security not in {"starttls", "ssl", "none"}:
raise RepositoryError("Ungültige SMTP-Verschlüsselung.")
if imap_security not in {"starttls", "ssl", "none"}:
raise RepositoryError("Ungültige IMAP-Verschlüsselung.")
if delivery_mode in {"send", "ask"} and not smtp_host.strip():
raise RepositoryError("Für den direkten Versand ist ein SMTP-Server erforderlich.")
if delivery_mode in {"drafts", "ask"} and not imap_host.strip():
raise RepositoryError("Für die Ablage als Entwurf ist ein IMAP-Server erforderlich.")
for label, port in (("Der SMTP-Port", smtp_port), ("Der IMAP-Port", imap_port)):
if port < 1 or port > 65535:
raise RepositoryError(f"{label} muss zwischen 1 und 65535 liegen.")
config = self.get_configuration()
config["email_settings"] = {
"delivery_mode": delivery_mode,
"smtp_host": smtp_host.strip(),
"smtp_port": int(smtp_port),
"smtp_security": smtp_security,
"smtp_username": smtp_username.strip(),
"smtp_password": smtp_password,
"imap_host": imap_host.strip(),
"imap_port": int(imap_port),
"imap_security": imap_security,
"imap_username": imap_username.strip(),
"imap_password": imap_password,
"imap_drafts_folder": imap_drafts_folder.strip() or "INBOX.Entwürfe",
}
write_json_atomic(self.root / "repository.json", config)
def save_organization(self, values: dict[str, str]) -> None:
organization = {key: str(value).strip() for key, value in values.items()}
organization["iban"] = normalize_iban(organization.get("iban", ""))