mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 03:20:49 +02:00
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:
co-authored by
Claude Sonnet 5
parent
b7554478ac
commit
6b0da82b45
@@ -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
|
||||
@@ -12,12 +12,15 @@ from pathlib import Path
|
||||
|
||||
from ccma.domain.contributions import claim_balance
|
||||
from ccma.domain.dates import format_date_for_display
|
||||
from ccma.services.mail_delivery import append_to_imap_drafts, imap_session, send_via_smtp, smtp_session
|
||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class GeneratedReminderMail:
|
||||
export_path: Path
|
||||
# None for "send"/"drafts" delivery -- there is no local file, the mail was
|
||||
# handed straight to the mail server. Always set for "local" delivery.
|
||||
export_path: Path | None
|
||||
archive_path: Path
|
||||
sha256: str
|
||||
|
||||
@@ -115,11 +118,14 @@ def generate_and_send_reminder_mail(
|
||||
claim_id: str,
|
||||
reminder_id: str,
|
||||
*,
|
||||
output_path: Path | str,
|
||||
delivery_mode: str,
|
||||
output_path: Path | str | None = None,
|
||||
sender_name: str,
|
||||
sender_email: str,
|
||||
signature: str,
|
||||
) -> GeneratedReminderMail:
|
||||
if delivery_mode not in {"local", "send", "drafts"}:
|
||||
raise RepositoryError(f"Unbekannter Versandmodus: {delivery_mode}")
|
||||
member = repository.get_member(member_id)
|
||||
data, claim = repository.get_claim(member_id, claim_id)
|
||||
reminder = repository._find_reminder(data, claim_id, reminder_id)
|
||||
@@ -129,10 +135,14 @@ def generate_and_send_reminder_mail(
|
||||
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}")
|
||||
target: Path | None = None
|
||||
if delivery_mode == "local":
|
||||
if not output_path:
|
||||
raise RepositoryError("Kein Zielpfad für die lokale Ablage angegeben.")
|
||||
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)
|
||||
@@ -155,15 +165,27 @@ def generate_and_send_reminder_mail(
|
||||
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")
|
||||
export_path: Path | None = None
|
||||
try:
|
||||
export_temp.write_bytes(content)
|
||||
archive_temp.write_bytes(content)
|
||||
os.replace(export_temp, target)
|
||||
if delivery_mode == "local":
|
||||
export_temp = target.with_name(f".{target.name}.tmp")
|
||||
try:
|
||||
export_temp.write_bytes(content)
|
||||
os.replace(export_temp, target)
|
||||
finally:
|
||||
export_temp.unlink(missing_ok=True)
|
||||
export_path = target
|
||||
elif delivery_mode == "send":
|
||||
with smtp_session(repository.get_email_settings()) as client:
|
||||
send_via_smtp(client, content)
|
||||
else:
|
||||
settings = repository.get_email_settings()
|
||||
with imap_session(settings) as client:
|
||||
append_to_imap_drafts(client, content, folder=settings["imap_drafts_folder"])
|
||||
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"
|
||||
@@ -184,6 +206,7 @@ def generate_and_send_reminder_mail(
|
||||
"recipient": member.email,
|
||||
"sha256": digest,
|
||||
"payment_deadline": str(sent_reminder.get("payment_deadline", "")),
|
||||
"delivery_mode": delivery_mode,
|
||||
},
|
||||
)
|
||||
return GeneratedReminderMail(target, archive_path, digest)
|
||||
return GeneratedReminderMail(export_path, archive_path, digest)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user