mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-08-24 14:35:19 +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.contributions import claim_balance
|
||||||
from ccma.domain.dates import format_date_for_display
|
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
|
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@dataclass(frozen=True, slots=True)
|
||||||
class GeneratedReminderMail:
|
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
|
archive_path: Path
|
||||||
sha256: str
|
sha256: str
|
||||||
|
|
||||||
@@ -115,11 +118,14 @@ def generate_and_send_reminder_mail(
|
|||||||
claim_id: str,
|
claim_id: str,
|
||||||
reminder_id: str,
|
reminder_id: str,
|
||||||
*,
|
*,
|
||||||
output_path: Path | str,
|
delivery_mode: str,
|
||||||
|
output_path: Path | str | None = None,
|
||||||
sender_name: str,
|
sender_name: str,
|
||||||
sender_email: str,
|
sender_email: str,
|
||||||
signature: str,
|
signature: str,
|
||||||
) -> GeneratedReminderMail:
|
) -> GeneratedReminderMail:
|
||||||
|
if delivery_mode not in {"local", "send", "drafts"}:
|
||||||
|
raise RepositoryError(f"Unbekannter Versandmodus: {delivery_mode}")
|
||||||
member = repository.get_member(member_id)
|
member = repository.get_member(member_id)
|
||||||
data, claim = repository.get_claim(member_id, claim_id)
|
data, claim = repository.get_claim(member_id, claim_id)
|
||||||
reminder = repository._find_reminder(data, claim_id, reminder_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.")
|
raise RepositoryError("Für das Mitglied ist keine E-Mail-Adresse hinterlegt.")
|
||||||
if not sender_email.strip() or "@" not in sender_email:
|
if not sender_email.strip() or "@" not in sender_email:
|
||||||
raise RepositoryError("Für die Mahnung ist eine gültige Absenderadresse erforderlich.")
|
raise RepositoryError("Für die Mahnung ist eine gültige Absenderadresse erforderlich.")
|
||||||
target = Path(output_path)
|
target: Path | None = None
|
||||||
target.parent.mkdir(parents=True, exist_ok=True)
|
if delivery_mode == "local":
|
||||||
if target.exists():
|
if not output_path:
|
||||||
raise RepositoryError(f"Die Ausgabedatei existiert bereits: {target}")
|
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)
|
sent_reminder = repository.mark_reminder_sent(member_id, claim_id, reminder_id)
|
||||||
updated_data, updated_claim = repository.get_claim(member_id, claim_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"
|
f"{_safe_filename(str(sent_reminder.get('name', 'Mahnung')))}.eml"
|
||||||
)
|
)
|
||||||
archive_path = _available_path(archive_dir, filename)
|
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")
|
archive_temp = archive_path.with_name(f".{archive_path.name}.tmp")
|
||||||
|
export_path: Path | None = None
|
||||||
try:
|
try:
|
||||||
export_temp.write_bytes(content)
|
|
||||||
archive_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)
|
os.replace(archive_temp, archive_path)
|
||||||
finally:
|
finally:
|
||||||
export_temp.unlink(missing_ok=True)
|
|
||||||
archive_temp.unlink(missing_ok=True)
|
archive_temp.unlink(missing_ok=True)
|
||||||
relative_path = archive_path.relative_to(
|
relative_path = archive_path.relative_to(
|
||||||
repository.members_root / member_id / "files"
|
repository.members_root / member_id / "files"
|
||||||
@@ -184,6 +206,7 @@ def generate_and_send_reminder_mail(
|
|||||||
"recipient": member.email,
|
"recipient": member.email,
|
||||||
"sha256": digest,
|
"sha256": digest,
|
||||||
"payment_deadline": str(sent_reminder.get("payment_deadline", "")),
|
"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 hashlib
|
||||||
import re
|
import re
|
||||||
|
from contextlib import nullcontext
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from datetime import date, datetime
|
from datetime import date, datetime
|
||||||
from email.message import EmailMessage
|
from email.message import EmailMessage
|
||||||
@@ -9,6 +10,7 @@ from email.policy import SMTP
|
|||||||
from email.utils import format_datetime, make_msgid
|
from email.utils import format_datetime, make_msgid
|
||||||
from pathlib import Path
|
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.services.sepa import DirectDebit
|
||||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||||
|
|
||||||
@@ -17,7 +19,8 @@ from ccma.storage.repository import MemberRepository, RepositoryError
|
|||||||
class GeneratedDebitMail:
|
class GeneratedDebitMail:
|
||||||
member_id: str
|
member_id: str
|
||||||
recipient: str
|
recipient: str
|
||||||
export_path: Path
|
# None for "send"/"drafts" delivery -- see reminder_mail.GeneratedReminderMail.
|
||||||
|
export_path: Path | None
|
||||||
archive_path: Path
|
archive_path: Path
|
||||||
|
|
||||||
|
|
||||||
@@ -94,67 +97,94 @@ def generate_debit_mails(
|
|||||||
debits: list[DirectDebit],
|
debits: list[DirectDebit],
|
||||||
*,
|
*,
|
||||||
collection_date: date,
|
collection_date: date,
|
||||||
output_directory: Path | str,
|
delivery_mode: str,
|
||||||
|
output_directory: Path | str | None = None,
|
||||||
sender_name: str,
|
sender_name: str,
|
||||||
sender_email: str,
|
sender_email: str,
|
||||||
signature: str,
|
signature: str,
|
||||||
) -> tuple[list[GeneratedDebitMail], list[str]]:
|
) -> tuple[list[GeneratedDebitMail], list[str]]:
|
||||||
output = Path(output_directory)
|
output: Path | None = None
|
||||||
output.mkdir(parents=True, exist_ok=True)
|
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 {}
|
organization = repository.get_configuration().get("organization") or {}
|
||||||
creditor_id = str(organization.get("creditor_id", "")).strip()
|
creditor_id = str(organization.get("creditor_id", "")).strip()
|
||||||
if not creditor_id:
|
if not creditor_id:
|
||||||
raise RepositoryError("In den Optionen fehlt die Gläubiger-ID.")
|
raise RepositoryError("In den Optionen fehlt die Gläubiger-ID.")
|
||||||
generated: list[GeneratedDebitMail] = []
|
generated: list[GeneratedDebitMail] = []
|
||||||
warnings: list[str] = []
|
warnings: list[str] = []
|
||||||
for debit in debits:
|
# One connection is opened for the whole batch (a run can cover dozens of
|
||||||
member = repository.get_member(debit.member_id)
|
# members) instead of reconnecting/re-authenticating per recipient.
|
||||||
if not member.email.strip():
|
with session as client:
|
||||||
warnings.append(f"{member.member_number or member.display_name}: E-Mail-Adresse fehlt.")
|
for debit in debits:
|
||||||
continue
|
member = repository.get_member(debit.member_id)
|
||||||
content = debit_mail_bytes(
|
if not member.email.strip():
|
||||||
recipient=member.email,
|
warnings.append(f"{member.member_number or member.display_name}: E-Mail-Adresse fehlt.")
|
||||||
first_name=member.first_name,
|
continue
|
||||||
debit=debit,
|
content = debit_mail_bytes(
|
||||||
collection_date=collection_date,
|
recipient=member.email,
|
||||||
creditor_id=creditor_id,
|
first_name=member.first_name,
|
||||||
sender_name=sender_name,
|
debit=debit,
|
||||||
sender_email=sender_email,
|
collection_date=collection_date,
|
||||||
signature=signature,
|
creditor_id=creditor_id,
|
||||||
)
|
sender_name=sender_name,
|
||||||
filename = (
|
sender_email=sender_email,
|
||||||
f"SEPA-Info-{collection_date.isoformat()}-"
|
signature=signature,
|
||||||
f"{_safe_filename(member.member_number or member.display_name)}.eml"
|
)
|
||||||
)
|
filename = (
|
||||||
export_path = _available_path(output, filename)
|
f"SEPA-Info-{collection_date.isoformat()}-"
|
||||||
archive_dir = repository.members_root / member.member_id / "files" / "documents" / "SEPA"
|
f"{_safe_filename(member.member_number or member.display_name)}.eml"
|
||||||
archive_dir.mkdir(parents=True, exist_ok=True)
|
)
|
||||||
archive_path = _available_path(archive_dir, filename)
|
archive_dir = repository.members_root / member.member_id / "files" / "documents" / "SEPA"
|
||||||
export_path.write_bytes(content)
|
archive_dir.mkdir(parents=True, exist_ok=True)
|
||||||
try:
|
archive_path = _available_path(archive_dir, filename)
|
||||||
archive_path.write_bytes(content)
|
export_path: Path | None = None
|
||||||
except OSError:
|
if delivery_mode == "local":
|
||||||
export_path.unlink(missing_ok=True)
|
export_path = _available_path(output, filename)
|
||||||
raise
|
export_path.write_bytes(content)
|
||||||
relative_path = archive_path.relative_to(
|
elif delivery_mode == "send":
|
||||||
repository.members_root / member.member_id / "files"
|
send_via_smtp(client, content)
|
||||||
).as_posix()
|
else:
|
||||||
digest = hashlib.sha256(content).hexdigest()
|
append_to_imap_drafts(client, content, folder=email_settings["imap_drafts_folder"])
|
||||||
repository.append_event(
|
try:
|
||||||
member.member_id,
|
archive_path.write_bytes(content)
|
||||||
event_type="sepa_notification_generated",
|
except OSError:
|
||||||
summary=f"SEPA-Info-Mail erzeugt: {archive_path.name}",
|
if export_path is not None:
|
||||||
actor_type="user",
|
export_path.unlink(missing_ok=True)
|
||||||
actor_name="Vorstand",
|
raise
|
||||||
references={"document": relative_path},
|
relative_path = archive_path.relative_to(
|
||||||
data={
|
repository.members_root / member.member_id / "files"
|
||||||
"sha256": digest,
|
).as_posix()
|
||||||
"recipient": member.email,
|
digest = hashlib.sha256(content).hexdigest()
|
||||||
"collection_date": collection_date.isoformat(),
|
repository.append_event(
|
||||||
"amount": f"{debit.amount:.2f}",
|
member.member_id,
|
||||||
},
|
event_type="sepa_notification_generated",
|
||||||
)
|
summary=f"SEPA-Info-Mail erzeugt: {archive_path.name}",
|
||||||
generated.append(
|
actor_type="user",
|
||||||
GeneratedDebitMail(member.member_id, member.email, export_path, archive_path)
|
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
|
return generated, warnings
|
||||||
|
|||||||
@@ -138,6 +138,23 @@ DEFAULT_CONFIGURATION = {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
"contribution_rules": DEFAULT_CONTRIBUTION_RULES,
|
"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)
|
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:
|
def save_organization(self, values: dict[str, str]) -> None:
|
||||||
organization = {key: str(value).strip() for key, value in values.items()}
|
organization = {key: str(value).strip() for key, value in values.items()}
|
||||||
organization["iban"] = normalize_iban(organization.get("iban", ""))
|
organization["iban"] = normalize_iban(organization.get("iban", ""))
|
||||||
|
|||||||
+23
-10
@@ -25,6 +25,7 @@ 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.services.reminder_mail import generate_and_send_reminder_mail
|
||||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||||
from ccma.ui.document_dialog import DocumentTemplateDialog
|
from ccma.ui.document_dialog import DocumentTemplateDialog
|
||||||
|
from ccma.ui.email_delivery_dialog import resolve_delivery_mode
|
||||||
from ccma.ui.labels import (
|
from ccma.ui.labels import (
|
||||||
CLAIM_ITEM_TYPE_LABELS,
|
CLAIM_ITEM_TYPE_LABELS,
|
||||||
REMINDER_CHANNEL_LABELS,
|
REMINDER_CHANNEL_LABELS,
|
||||||
@@ -1258,22 +1259,29 @@ class ReminderMailDialog(tk.Toplevel):
|
|||||||
self.grab_set()
|
self.grab_set()
|
||||||
|
|
||||||
def _generate(self) -> None:
|
def _generate(self) -> None:
|
||||||
selected = filedialog.asksaveasfilename(
|
delivery_mode = resolve_delivery_mode(self, self.repository)
|
||||||
parent=self,
|
if delivery_mode is None:
|
||||||
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
|
return
|
||||||
|
output_path = None
|
||||||
|
if delivery_mode == "local":
|
||||||
|
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
|
||||||
|
output_path = selected
|
||||||
try:
|
try:
|
||||||
result = generate_and_send_reminder_mail(
|
result = generate_and_send_reminder_mail(
|
||||||
self.repository,
|
self.repository,
|
||||||
self.member_id,
|
self.member_id,
|
||||||
self.claim_id,
|
self.claim_id,
|
||||||
self.reminder_id,
|
self.reminder_id,
|
||||||
output_path=selected,
|
delivery_mode=delivery_mode,
|
||||||
|
output_path=output_path,
|
||||||
sender_name=self.sender_name_var.get(),
|
sender_name=self.sender_name_var.get(),
|
||||||
sender_email=self.sender_email_var.get(),
|
sender_email=self.sender_email_var.get(),
|
||||||
signature=self.signature.get("1.0", "end-1c"),
|
signature=self.signature.get("1.0", "end-1c"),
|
||||||
@@ -1281,9 +1289,14 @@ class ReminderMailDialog(tk.Toplevel):
|
|||||||
except (OSError, RepositoryError) as exc:
|
except (OSError, RepositoryError) as exc:
|
||||||
messagebox.showerror("Mahnung konnte nicht erzeugt werden", str(exc), parent=self)
|
messagebox.showerror("Mahnung konnte nicht erzeugt werden", str(exc), parent=self)
|
||||||
return
|
return
|
||||||
|
outcome = {
|
||||||
|
"local": f"gespeichert:\n{result.export_path}",
|
||||||
|
"send": "direkt versandt.",
|
||||||
|
"drafts": "als Entwurf im Postfach abgelegt.",
|
||||||
|
}[delivery_mode]
|
||||||
messagebox.showinfo(
|
messagebox.showinfo(
|
||||||
"Mahnung erzeugt",
|
"Mahnung erzeugt",
|
||||||
f"Die E-Mail wurde gespeichert und als versandt gebucht:\n{result.export_path}",
|
f"Die E-Mail wurde {outcome}\n\nDie Mahnung wurde als versandt gebucht.",
|
||||||
parent=self,
|
parent=self,
|
||||||
)
|
)
|
||||||
self.destroy()
|
self.destroy()
|
||||||
|
|||||||
@@ -0,0 +1,68 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import tkinter as tk
|
||||||
|
from tkinter import ttk
|
||||||
|
|
||||||
|
from ccma.storage.repository import MemberRepository
|
||||||
|
|
||||||
|
|
||||||
|
class EmailDeliveryChoiceDialog(tk.Toplevel):
|
||||||
|
"""Shown once per generation action (not per individual e-mail) when the
|
||||||
|
repository's delivery mode is "ask" -- lets the board decide per-batch whether
|
||||||
|
to send immediately or park the e-mail(s) as a draft for later review."""
|
||||||
|
|
||||||
|
def __init__(self, master: tk.Misc, *, can_send: bool, can_draft: bool):
|
||||||
|
super().__init__(master)
|
||||||
|
self.result: str | None = None
|
||||||
|
self.title("E-Mail-Versand")
|
||||||
|
self.transient(master.winfo_toplevel())
|
||||||
|
self.resizable(False, False)
|
||||||
|
frame = ttk.Frame(self, padding=18)
|
||||||
|
frame.pack(fill="both", expand=True)
|
||||||
|
ttk.Label(
|
||||||
|
frame,
|
||||||
|
text="Wie soll mit dieser E-Mail verfahren werden?",
|
||||||
|
wraplength=360,
|
||||||
|
justify="left",
|
||||||
|
).pack(anchor="w", pady=(0, 14))
|
||||||
|
buttons = ttk.Frame(frame)
|
||||||
|
buttons.pack(anchor="e")
|
||||||
|
ttk.Button(buttons, text="Abbrechen", command=self._cancel).pack(side="left", padx=(0, 8))
|
||||||
|
send_button = ttk.Button(buttons, text="Senden", command=self._choose_send)
|
||||||
|
send_button.pack(side="left", padx=(0, 8))
|
||||||
|
send_button.configure(state="normal" if can_send else "disabled")
|
||||||
|
draft_button = ttk.Button(
|
||||||
|
buttons, text="Als Entwurf ablegen", style="Accent.TButton", command=self._choose_drafts
|
||||||
|
)
|
||||||
|
draft_button.pack(side="left")
|
||||||
|
draft_button.configure(state="normal" if can_draft else "disabled")
|
||||||
|
self.bind("<Escape>", lambda _event: self._cancel())
|
||||||
|
self.protocol("WM_DELETE_WINDOW", self._cancel)
|
||||||
|
self.after_idle(self.grab_set)
|
||||||
|
|
||||||
|
def _choose_send(self) -> None:
|
||||||
|
self.result = "send"
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
|
def _choose_drafts(self) -> None:
|
||||||
|
self.result = "drafts"
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
|
def _cancel(self) -> None:
|
||||||
|
self.result = None
|
||||||
|
self.destroy()
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_delivery_mode(master: tk.Misc, repository: MemberRepository) -> str | None:
|
||||||
|
"""Returns the delivery mode to use for the next generated e-mail(s): "local",
|
||||||
|
"send" or "drafts" -- or None if the board cancelled out of the "ask every time"
|
||||||
|
prompt, in which case the caller should abort without generating anything."""
|
||||||
|
settings = repository.get_email_settings()
|
||||||
|
mode = settings["delivery_mode"]
|
||||||
|
if mode in {"local", "send", "drafts"}:
|
||||||
|
return mode
|
||||||
|
dialog = EmailDeliveryChoiceDialog(
|
||||||
|
master, can_send=bool(settings["smtp_host"]), can_draft=bool(settings["imap_host"])
|
||||||
|
)
|
||||||
|
master.wait_window(dialog)
|
||||||
|
return dialog.result
|
||||||
@@ -30,6 +30,19 @@ REMINDER_CHANNEL_LABELS = {
|
|||||||
"personal": "Persönlich",
|
"personal": "Persönlich",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
EMAIL_DELIVERY_MODE_LABELS = {
|
||||||
|
"local": "Lokal speichern (bisheriges Verhalten)",
|
||||||
|
"send": "Direkt versenden",
|
||||||
|
"drafts": "Als Entwurf im Postfach ablegen",
|
||||||
|
"ask": "Jedes Mal fragen",
|
||||||
|
}
|
||||||
|
|
||||||
|
EMAIL_SECURITY_LABELS = {
|
||||||
|
"starttls": "STARTTLS",
|
||||||
|
"ssl": "SSL/TLS",
|
||||||
|
"none": "Keine",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def display_label(labels: Mapping[str, str], key: str) -> str:
|
def display_label(labels: Mapping[str, str], key: str) -> str:
|
||||||
return labels.get(key, key)
|
return labels.get(key, key)
|
||||||
|
|||||||
@@ -12,10 +12,17 @@ from ccma.services.intervals import (
|
|||||||
IntervalValidationError,
|
IntervalValidationError,
|
||||||
normalize_anniversary_intervals,
|
normalize_anniversary_intervals,
|
||||||
)
|
)
|
||||||
|
from ccma.services.mail_delivery import test_imap_connection, test_smtp_connection
|
||||||
from ccma.storage.repository import MemberRepository, RepositoryError, validate_member_number_pattern
|
from ccma.storage.repository import MemberRepository, RepositoryError, validate_member_number_pattern
|
||||||
from ccma.ui.changelog_view import ChangelogView
|
from ccma.ui.changelog_view import ChangelogView
|
||||||
from ccma.ui.icons import IconStore
|
from ccma.ui.icons import IconStore
|
||||||
from ccma.ui.labels import THEME_LABELS, display_label, storage_key
|
from ccma.ui.labels import (
|
||||||
|
EMAIL_DELIVERY_MODE_LABELS,
|
||||||
|
EMAIL_SECURITY_LABELS,
|
||||||
|
THEME_LABELS,
|
||||||
|
display_label,
|
||||||
|
storage_key,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class OptionsDialog(tk.Toplevel):
|
class OptionsDialog(tk.Toplevel):
|
||||||
@@ -94,6 +101,7 @@ class OptionsDialog(tk.Toplevel):
|
|||||||
organization = ttk.Frame(notebook, padding=16)
|
organization = ttk.Frame(notebook, padding=16)
|
||||||
automation = ttk.Frame(notebook, padding=16)
|
automation = ttk.Frame(notebook, padding=16)
|
||||||
reminders = ttk.Frame(notebook, padding=16)
|
reminders = ttk.Frame(notebook, padding=16)
|
||||||
|
email = ttk.Frame(notebook, padding=16)
|
||||||
changelog = ChangelogView(notebook)
|
changelog = ChangelogView(notebook)
|
||||||
notebook.add(paths, text="Pfade")
|
notebook.add(paths, text="Pfade")
|
||||||
notebook.add(appearance, text="Darstellung")
|
notebook.add(appearance, text="Darstellung")
|
||||||
@@ -101,6 +109,7 @@ class OptionsDialog(tk.Toplevel):
|
|||||||
notebook.add(organization, text="Verein / Absender")
|
notebook.add(organization, text="Verein / Absender")
|
||||||
notebook.add(automation, text="Hausmeister")
|
notebook.add(automation, text="Hausmeister")
|
||||||
notebook.add(reminders, text="Mahnungen")
|
notebook.add(reminders, text="Mahnungen")
|
||||||
|
notebook.add(email, text="E-Mail-Versand")
|
||||||
notebook.add(changelog, text="Changelog")
|
notebook.add(changelog, text="Changelog")
|
||||||
self._build_paths(paths)
|
self._build_paths(paths)
|
||||||
self._build_appearance(appearance)
|
self._build_appearance(appearance)
|
||||||
@@ -108,6 +117,7 @@ class OptionsDialog(tk.Toplevel):
|
|||||||
self._build_organization(organization)
|
self._build_organization(organization)
|
||||||
self._build_automation(automation)
|
self._build_automation(automation)
|
||||||
self._build_reminders(reminders)
|
self._build_reminders(reminders)
|
||||||
|
self._build_email(email)
|
||||||
|
|
||||||
buttons = ttk.Frame(root)
|
buttons = ttk.Frame(root)
|
||||||
buttons.grid(row=1, column=0, sticky="e", pady=(12, 0))
|
buttons.grid(row=1, column=0, sticky="e", pady=(12, 0))
|
||||||
@@ -576,6 +586,167 @@ class OptionsDialog(tk.Toplevel):
|
|||||||
del self.standard_items[int(selected[0])]
|
del self.standard_items[int(selected[0])]
|
||||||
self._refresh_standard_items()
|
self._refresh_standard_items()
|
||||||
|
|
||||||
|
def _build_email(self, parent: ttk.Frame) -> None:
|
||||||
|
parent.columnconfigure(0, weight=1)
|
||||||
|
email_settings = self.repository.get_email_settings()
|
||||||
|
|
||||||
|
self.email_delivery_mode_var = tk.StringVar(
|
||||||
|
value=display_label(EMAIL_DELIVERY_MODE_LABELS, email_settings["delivery_mode"])
|
||||||
|
)
|
||||||
|
ttk.Label(parent, text="Versandmodus").grid(row=0, column=0, sticky="w", pady=6)
|
||||||
|
ttk.Combobox(
|
||||||
|
parent,
|
||||||
|
textvariable=self.email_delivery_mode_var,
|
||||||
|
values=list(EMAIL_DELIVERY_MODE_LABELS.values()),
|
||||||
|
state="readonly",
|
||||||
|
width=42,
|
||||||
|
).grid(row=0, column=1, sticky="w", pady=6)
|
||||||
|
ttk.Label(
|
||||||
|
parent,
|
||||||
|
text=(
|
||||||
|
"Gilt für Mahnungs- und SEPA-Info-Mails. „Jedes Mal fragen“ zeigt vor jeder "
|
||||||
|
"Erzeugung eine Auswahl zwischen Senden und Entwurf ablegen."
|
||||||
|
),
|
||||||
|
style="Muted.TLabel",
|
||||||
|
wraplength=680,
|
||||||
|
).grid(row=1, column=0, columnspan=2, sticky="w", pady=(0, 12))
|
||||||
|
|
||||||
|
smtp_frame = ttk.LabelFrame(parent, text="SMTP (zum direkten Versenden)", padding=10)
|
||||||
|
smtp_frame.grid(row=2, column=0, columnspan=2, sticky="ew", pady=(4, 0))
|
||||||
|
smtp_frame.columnconfigure(1, weight=1)
|
||||||
|
self.smtp_host_var = tk.StringVar(value=email_settings["smtp_host"])
|
||||||
|
self.smtp_port_var = tk.StringVar(value=str(email_settings["smtp_port"]))
|
||||||
|
self.smtp_security_var = tk.StringVar(
|
||||||
|
value=display_label(EMAIL_SECURITY_LABELS, email_settings["smtp_security"])
|
||||||
|
)
|
||||||
|
self.smtp_username_var = tk.StringVar(value=email_settings["smtp_username"])
|
||||||
|
self.smtp_password_var = tk.StringVar(value=email_settings["smtp_password"])
|
||||||
|
ttk.Label(smtp_frame, text="Server").grid(row=0, column=0, sticky="w", pady=4, padx=(0, 8))
|
||||||
|
ttk.Entry(smtp_frame, textvariable=self.smtp_host_var).grid(
|
||||||
|
row=0, column=1, sticky="ew", pady=4, padx=(0, 8)
|
||||||
|
)
|
||||||
|
ttk.Label(smtp_frame, text="Port").grid(row=0, column=2, sticky="w", pady=4, padx=(0, 8))
|
||||||
|
ttk.Entry(smtp_frame, textvariable=self.smtp_port_var, width=8).grid(row=0, column=3, pady=4)
|
||||||
|
ttk.Label(smtp_frame, text="Verschlüsselung").grid(
|
||||||
|
row=1, column=0, sticky="w", pady=4, padx=(0, 8)
|
||||||
|
)
|
||||||
|
ttk.Combobox(
|
||||||
|
smtp_frame,
|
||||||
|
textvariable=self.smtp_security_var,
|
||||||
|
values=list(EMAIL_SECURITY_LABELS.values()),
|
||||||
|
state="readonly",
|
||||||
|
width=14,
|
||||||
|
).grid(row=1, column=1, sticky="w", pady=4)
|
||||||
|
ttk.Label(smtp_frame, text="Benutzername").grid(row=2, column=0, sticky="w", pady=4, padx=(0, 8))
|
||||||
|
ttk.Entry(smtp_frame, textvariable=self.smtp_username_var).grid(
|
||||||
|
row=2, column=1, columnspan=3, sticky="ew", pady=4
|
||||||
|
)
|
||||||
|
ttk.Label(smtp_frame, text="Passwort").grid(row=3, column=0, sticky="w", pady=4, padx=(0, 8))
|
||||||
|
ttk.Entry(smtp_frame, textvariable=self.smtp_password_var, show="•").grid(
|
||||||
|
row=3, column=1, columnspan=3, sticky="ew", pady=4
|
||||||
|
)
|
||||||
|
ttk.Button(smtp_frame, text="Verbindung testen", command=self._test_smtp_connection).grid(
|
||||||
|
row=4, column=0, columnspan=4, sticky="w", pady=(8, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
imap_frame = ttk.LabelFrame(parent, text="IMAP (für die Ablage als Entwurf)", padding=10)
|
||||||
|
imap_frame.grid(row=3, column=0, columnspan=2, sticky="ew", pady=(16, 0))
|
||||||
|
imap_frame.columnconfigure(1, weight=1)
|
||||||
|
self.imap_host_var = tk.StringVar(value=email_settings["imap_host"])
|
||||||
|
self.imap_port_var = tk.StringVar(value=str(email_settings["imap_port"]))
|
||||||
|
self.imap_security_var = tk.StringVar(
|
||||||
|
value=display_label(EMAIL_SECURITY_LABELS, email_settings["imap_security"])
|
||||||
|
)
|
||||||
|
self.imap_username_var = tk.StringVar(value=email_settings["imap_username"])
|
||||||
|
self.imap_password_var = tk.StringVar(value=email_settings["imap_password"])
|
||||||
|
self.imap_drafts_folder_var = tk.StringVar(value=email_settings["imap_drafts_folder"])
|
||||||
|
ttk.Label(imap_frame, text="Server").grid(row=0, column=0, sticky="w", pady=4, padx=(0, 8))
|
||||||
|
ttk.Entry(imap_frame, textvariable=self.imap_host_var).grid(
|
||||||
|
row=0, column=1, sticky="ew", pady=4, padx=(0, 8)
|
||||||
|
)
|
||||||
|
ttk.Label(imap_frame, text="Port").grid(row=0, column=2, sticky="w", pady=4, padx=(0, 8))
|
||||||
|
ttk.Entry(imap_frame, textvariable=self.imap_port_var, width=8).grid(row=0, column=3, pady=4)
|
||||||
|
ttk.Label(imap_frame, text="Verschlüsselung").grid(
|
||||||
|
row=1, column=0, sticky="w", pady=4, padx=(0, 8)
|
||||||
|
)
|
||||||
|
ttk.Combobox(
|
||||||
|
imap_frame,
|
||||||
|
textvariable=self.imap_security_var,
|
||||||
|
values=list(EMAIL_SECURITY_LABELS.values()),
|
||||||
|
state="readonly",
|
||||||
|
width=14,
|
||||||
|
).grid(row=1, column=1, sticky="w", pady=4)
|
||||||
|
ttk.Label(imap_frame, text="Benutzername").grid(row=2, column=0, sticky="w", pady=4, padx=(0, 8))
|
||||||
|
ttk.Entry(imap_frame, textvariable=self.imap_username_var).grid(
|
||||||
|
row=2, column=1, columnspan=3, sticky="ew", pady=4
|
||||||
|
)
|
||||||
|
ttk.Label(imap_frame, text="Passwort").grid(row=3, column=0, sticky="w", pady=4, padx=(0, 8))
|
||||||
|
ttk.Entry(imap_frame, textvariable=self.imap_password_var, show="•").grid(
|
||||||
|
row=3, column=1, columnspan=3, sticky="ew", pady=4
|
||||||
|
)
|
||||||
|
ttk.Label(imap_frame, text="Entwürfe-Ordner").grid(row=4, column=0, sticky="w", pady=4, padx=(0, 8))
|
||||||
|
ttk.Entry(imap_frame, textvariable=self.imap_drafts_folder_var).grid(
|
||||||
|
row=4, column=1, columnspan=3, sticky="ew", pady=4
|
||||||
|
)
|
||||||
|
ttk.Button(imap_frame, text="Verbindung testen", command=self._test_imap_connection).grid(
|
||||||
|
row=5, column=0, columnspan=4, sticky="w", pady=(8, 0)
|
||||||
|
)
|
||||||
|
|
||||||
|
ttk.Label(
|
||||||
|
parent,
|
||||||
|
text=(
|
||||||
|
"Server, Zugangsdaten und Passwörter werden mit den übrigen Vereinseinstellungen "
|
||||||
|
"in repository.json innerhalb dieses Mitglieder-Stores gespeichert -- pro Store "
|
||||||
|
"lässt sich so ein eigenes Postfach hinterlegen."
|
||||||
|
),
|
||||||
|
style="Muted.TLabel",
|
||||||
|
wraplength=680,
|
||||||
|
).grid(row=4, column=0, columnspan=2, sticky="w", pady=(12, 0))
|
||||||
|
|
||||||
|
def _collect_email_settings(self) -> dict:
|
||||||
|
return {
|
||||||
|
"delivery_mode": storage_key(EMAIL_DELIVERY_MODE_LABELS, self.email_delivery_mode_var.get()),
|
||||||
|
"smtp_host": self.smtp_host_var.get(),
|
||||||
|
"smtp_port": self.smtp_port_var.get(),
|
||||||
|
"smtp_security": storage_key(EMAIL_SECURITY_LABELS, self.smtp_security_var.get()),
|
||||||
|
"smtp_username": self.smtp_username_var.get(),
|
||||||
|
"smtp_password": self.smtp_password_var.get(),
|
||||||
|
"imap_host": self.imap_host_var.get(),
|
||||||
|
"imap_port": self.imap_port_var.get(),
|
||||||
|
"imap_security": storage_key(EMAIL_SECURITY_LABELS, self.imap_security_var.get()),
|
||||||
|
"imap_username": self.imap_username_var.get(),
|
||||||
|
"imap_password": self.imap_password_var.get(),
|
||||||
|
"imap_drafts_folder": self.imap_drafts_folder_var.get(),
|
||||||
|
}
|
||||||
|
|
||||||
|
def _test_smtp_connection(self) -> None:
|
||||||
|
settings = self._collect_email_settings()
|
||||||
|
try:
|
||||||
|
settings["smtp_port"] = int(settings["smtp_port"])
|
||||||
|
except ValueError:
|
||||||
|
messagebox.showerror("Ungültiger Port", "Der SMTP-Port muss eine ganze Zahl sein.", parent=self)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
test_smtp_connection(settings)
|
||||||
|
except RepositoryError as exc:
|
||||||
|
messagebox.showerror("SMTP-Verbindung fehlgeschlagen", str(exc), parent=self)
|
||||||
|
return
|
||||||
|
messagebox.showinfo("SMTP-Verbindung", "Verbindung und Anmeldung erfolgreich.", parent=self)
|
||||||
|
|
||||||
|
def _test_imap_connection(self) -> None:
|
||||||
|
settings = self._collect_email_settings()
|
||||||
|
try:
|
||||||
|
settings["imap_port"] = int(settings["imap_port"])
|
||||||
|
except ValueError:
|
||||||
|
messagebox.showerror("Ungültiger Port", "Der IMAP-Port muss eine ganze Zahl sein.", parent=self)
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
test_imap_connection(settings)
|
||||||
|
except RepositoryError as exc:
|
||||||
|
messagebox.showerror("IMAP-Verbindung fehlgeschlagen", str(exc), parent=self)
|
||||||
|
return
|
||||||
|
messagebox.showinfo("IMAP-Verbindung", "Verbindung und Anmeldung erfolgreich.", parent=self)
|
||||||
|
|
||||||
def _choose_store(self) -> None:
|
def _choose_store(self) -> None:
|
||||||
current = Path(self.store_var.get()).expanduser() if self.store_var.get().strip() else Path.home()
|
current = Path(self.store_var.get()).expanduser() if self.store_var.get().strip() else Path.home()
|
||||||
initial = current if current.is_dir() else current.parent
|
initial = current if current.is_dir() else current.parent
|
||||||
@@ -621,6 +792,15 @@ class OptionsDialog(tk.Toplevel):
|
|||||||
except (ValueError, IntervalValidationError) as exc:
|
except (ValueError, IntervalValidationError) as exc:
|
||||||
messagebox.showerror("Ungültige Hausmeister-Einstellung", str(exc), parent=self)
|
messagebox.showerror("Ungültige Hausmeister-Einstellung", str(exc), parent=self)
|
||||||
return
|
return
|
||||||
|
email_settings = self._collect_email_settings()
|
||||||
|
try:
|
||||||
|
email_settings["smtp_port"] = int(email_settings["smtp_port"])
|
||||||
|
email_settings["imap_port"] = int(email_settings["imap_port"])
|
||||||
|
except ValueError:
|
||||||
|
messagebox.showerror(
|
||||||
|
"Ungültige E-Mail-Einstellung", "SMTP-/IMAP-Port müssen ganze Zahlen sein.", parent=self
|
||||||
|
)
|
||||||
|
return
|
||||||
store_text = self.store_var.get().strip()
|
store_text = self.store_var.get().strip()
|
||||||
if not store_text:
|
if not store_text:
|
||||||
messagebox.showerror(
|
messagebox.showerror(
|
||||||
@@ -685,6 +865,7 @@ class OptionsDialog(tk.Toplevel):
|
|||||||
levels=self.levels,
|
levels=self.levels,
|
||||||
standard_fee_items=self.standard_items,
|
standard_fee_items=self.standard_items,
|
||||||
)
|
)
|
||||||
|
self.repository.save_email_settings(**email_settings)
|
||||||
except (OSError, RepositoryError) as exc:
|
except (OSError, RepositoryError) as exc:
|
||||||
messagebox.showerror("Optionen konnten nicht gespeichert werden", str(exc), parent=self)
|
messagebox.showerror("Optionen konnten nicht gespeichert werden", str(exc), parent=self)
|
||||||
return
|
return
|
||||||
|
|||||||
+22
-12
@@ -10,6 +10,7 @@ from ccma.domain.dates import DateValidationError, normalize_date_input
|
|||||||
from ccma.services.sepa import pending_direct_debits, write_csv, write_pain008
|
from ccma.services.sepa import pending_direct_debits, write_csv, write_pain008
|
||||||
from ccma.services.sepa_mail import generate_debit_mails
|
from ccma.services.sepa_mail import generate_debit_mails
|
||||||
from ccma.storage.repository import MemberRepository, RepositoryError
|
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||||
|
from ccma.ui.email_delivery_dialog import resolve_delivery_mode
|
||||||
|
|
||||||
|
|
||||||
def _next_weekday(selected: date) -> date:
|
def _next_weekday(selected: date) -> date:
|
||||||
@@ -277,26 +278,33 @@ class SepaMailExportDialog(tk.Toplevel):
|
|||||||
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left")
|
ttk.Button(buttons, text="Abbrechen", command=self.destroy).pack(side="left")
|
||||||
ttk.Button(
|
ttk.Button(
|
||||||
buttons,
|
buttons,
|
||||||
text="Ordner wählen und Entwürfe erzeugen",
|
text="Info-Mails erzeugen",
|
||||||
style="Accent.TButton",
|
style="Accent.TButton",
|
||||||
command=self._generate,
|
command=self._generate,
|
||||||
).pack(side="left", padx=(8, 0))
|
).pack(side="left", padx=(8, 0))
|
||||||
self.grab_set()
|
self.grab_set()
|
||||||
|
|
||||||
def _generate(self) -> None:
|
def _generate(self) -> None:
|
||||||
selected = filedialog.askdirectory(
|
delivery_mode = resolve_delivery_mode(self, self.repository)
|
||||||
parent=self,
|
if delivery_mode is None:
|
||||||
title="Ausgabeordner für Thunderbird-Entwürfe auswählen",
|
|
||||||
mustexist=True,
|
|
||||||
)
|
|
||||||
if not selected:
|
|
||||||
return
|
return
|
||||||
|
output_directory = None
|
||||||
|
if delivery_mode == "local":
|
||||||
|
selected = filedialog.askdirectory(
|
||||||
|
parent=self,
|
||||||
|
title="Ausgabeordner für Thunderbird-Entwürfe auswählen",
|
||||||
|
mustexist=True,
|
||||||
|
)
|
||||||
|
if not selected:
|
||||||
|
return
|
||||||
|
output_directory = selected
|
||||||
try:
|
try:
|
||||||
generated, warnings = generate_debit_mails(
|
generated, warnings = generate_debit_mails(
|
||||||
self.repository,
|
self.repository,
|
||||||
self.debits,
|
self.debits,
|
||||||
collection_date=self.collection_date,
|
collection_date=self.collection_date,
|
||||||
output_directory=selected,
|
delivery_mode=delivery_mode,
|
||||||
|
output_directory=output_directory,
|
||||||
sender_name=self.sender_name_var.get(),
|
sender_name=self.sender_name_var.get(),
|
||||||
sender_email=self.sender_email_var.get(),
|
sender_email=self.sender_email_var.get(),
|
||||||
signature=self.signature.get("1.0", "end-1c"),
|
signature=self.signature.get("1.0", "end-1c"),
|
||||||
@@ -304,10 +312,12 @@ class SepaMailExportDialog(tk.Toplevel):
|
|||||||
except (OSError, RepositoryError) as exc:
|
except (OSError, RepositoryError) as exc:
|
||||||
messagebox.showerror("Info-Mails konnten nicht erzeugt werden", str(exc), parent=self)
|
messagebox.showerror("Info-Mails konnten nicht erzeugt werden", str(exc), parent=self)
|
||||||
return
|
return
|
||||||
detail = (
|
location = {
|
||||||
f"{len(generated)} Entwurf/Entwürfe wurden in\n{selected}\nerzeugt und in den "
|
"local": f"in\n{output_directory}\nerzeugt",
|
||||||
"Mitgliederakten archiviert."
|
"send": "direkt versandt",
|
||||||
)
|
"drafts": "als Entwurf im Postfach abgelegt",
|
||||||
|
}[delivery_mode]
|
||||||
|
detail = f"{len(generated)} Info-Mail(s) wurden {location} und in den Mitgliederakten archiviert."
|
||||||
if warnings:
|
if warnings:
|
||||||
detail += "\n\nÜbersprungen:\n" + "\n".join(warnings[:12])
|
detail += "\n\nÜbersprungen:\n" + "\n".join(warnings[:12])
|
||||||
messagebox.showinfo("Info-Mails erzeugt", detail, parent=self)
|
messagebox.showinfo("Info-Mails erzeugt", detail, parent=self)
|
||||||
|
|||||||
@@ -0,0 +1,103 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from ccma.storage.repository import MemberRepository, RepositoryError
|
||||||
|
|
||||||
|
|
||||||
|
def test_email_settings_default_to_local_delivery_for_new_repositories(tmp_path) -> None:
|
||||||
|
repository = MemberRepository(tmp_path)
|
||||||
|
repository.initialize()
|
||||||
|
|
||||||
|
settings = repository.get_email_settings()
|
||||||
|
|
||||||
|
assert settings["delivery_mode"] == "local"
|
||||||
|
assert settings["smtp_host"] == ""
|
||||||
|
assert settings["imap_host"] == ""
|
||||||
|
|
||||||
|
|
||||||
|
def test_email_settings_round_trip_through_save_and_get(tmp_path) -> None:
|
||||||
|
repository = MemberRepository(tmp_path)
|
||||||
|
repository.initialize()
|
||||||
|
|
||||||
|
repository.save_email_settings(
|
||||||
|
delivery_mode="ask",
|
||||||
|
smtp_host="mail.example.org",
|
||||||
|
smtp_port=587,
|
||||||
|
smtp_security="starttls",
|
||||||
|
smtp_username="board@example.org",
|
||||||
|
smtp_password="secret",
|
||||||
|
imap_host="mail.example.org",
|
||||||
|
imap_port=993,
|
||||||
|
imap_security="ssl",
|
||||||
|
imap_username="board@example.org",
|
||||||
|
imap_password="secret",
|
||||||
|
imap_drafts_folder="INBOX.Entwürfe",
|
||||||
|
)
|
||||||
|
|
||||||
|
settings = repository.get_email_settings()
|
||||||
|
assert settings["delivery_mode"] == "ask"
|
||||||
|
assert settings["smtp_host"] == "mail.example.org"
|
||||||
|
assert settings["smtp_port"] == 587
|
||||||
|
assert settings["smtp_password"] == "secret"
|
||||||
|
assert settings["imap_drafts_folder"] == "INBOX.Entwürfe"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("overrides", "match"),
|
||||||
|
[
|
||||||
|
({"delivery_mode": "carrier-pigeon"}, "Ungültiger Versandmodus"),
|
||||||
|
({"smtp_security": "rot13"}, "Ungültige SMTP-Verschlüsselung"),
|
||||||
|
({"imap_security": "rot13"}, "Ungültige IMAP-Verschlüsselung"),
|
||||||
|
({"delivery_mode": "send", "smtp_host": ""}, "SMTP-Server erforderlich"),
|
||||||
|
({"delivery_mode": "drafts", "imap_host": ""}, "IMAP-Server erforderlich"),
|
||||||
|
({"delivery_mode": "ask", "imap_host": ""}, "IMAP-Server erforderlich"),
|
||||||
|
({"smtp_port": 0}, "SMTP-Port"),
|
||||||
|
({"smtp_port": 70000}, "SMTP-Port"),
|
||||||
|
({"imap_port": 0}, "IMAP-Port"),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_email_settings_rejects_invalid_input(tmp_path, overrides, match) -> None:
|
||||||
|
repository = MemberRepository(tmp_path)
|
||||||
|
repository.initialize()
|
||||||
|
values = {
|
||||||
|
"delivery_mode": "send",
|
||||||
|
"smtp_host": "mail.example.org",
|
||||||
|
"smtp_port": 587,
|
||||||
|
"smtp_security": "starttls",
|
||||||
|
"smtp_username": "",
|
||||||
|
"smtp_password": "",
|
||||||
|
"imap_host": "mail.example.org",
|
||||||
|
"imap_port": 993,
|
||||||
|
"imap_security": "ssl",
|
||||||
|
"imap_username": "",
|
||||||
|
"imap_password": "",
|
||||||
|
"imap_drafts_folder": "",
|
||||||
|
}
|
||||||
|
values.update(overrides)
|
||||||
|
|
||||||
|
with pytest.raises(RepositoryError, match=match):
|
||||||
|
repository.save_email_settings(**values)
|
||||||
|
|
||||||
|
|
||||||
|
def test_email_settings_local_mode_needs_no_server(tmp_path) -> None:
|
||||||
|
repository = MemberRepository(tmp_path)
|
||||||
|
repository.initialize()
|
||||||
|
|
||||||
|
repository.save_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="",
|
||||||
|
)
|
||||||
|
|
||||||
|
settings = repository.get_email_settings()
|
||||||
|
assert settings["delivery_mode"] == "local"
|
||||||
|
# Blank folder falls back to a sensible default instead of staying empty.
|
||||||
|
assert settings["imap_drafts_folder"] == "INBOX.Entwürfe"
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
import pytest
|
||||||
|
|
||||||
|
from ccma.services import mail_delivery
|
||||||
|
from ccma.storage.repository import RepositoryError
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeSmtpClient:
|
||||||
|
instances: list["_FakeSmtpClient"] = []
|
||||||
|
|
||||||
|
def __init__(self, host, port, **kwargs):
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.kwargs = kwargs
|
||||||
|
self.calls: list[str] = []
|
||||||
|
self.login_args: tuple | None = None
|
||||||
|
self.sent_messages: list = []
|
||||||
|
_FakeSmtpClient.instances.append(self)
|
||||||
|
|
||||||
|
def starttls(self, **kwargs):
|
||||||
|
self.calls.append("starttls")
|
||||||
|
|
||||||
|
def login(self, username, password):
|
||||||
|
self.login_args = (username, password)
|
||||||
|
self.calls.append("login")
|
||||||
|
|
||||||
|
def send_message(self, message):
|
||||||
|
self.sent_messages.append(message)
|
||||||
|
self.calls.append("send_message")
|
||||||
|
|
||||||
|
def quit(self):
|
||||||
|
self.calls.append("quit")
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeImapClient:
|
||||||
|
instances: list["_FakeImapClient"] = []
|
||||||
|
error = Exception
|
||||||
|
|
||||||
|
def __init__(self, host, port, **kwargs):
|
||||||
|
self.host = host
|
||||||
|
self.port = port
|
||||||
|
self.kwargs = kwargs
|
||||||
|
self.calls: list[str] = []
|
||||||
|
self.login_args: tuple | None = None
|
||||||
|
self.appended: list[tuple] = []
|
||||||
|
self.append_result = ("OK", [b"APPEND completed"])
|
||||||
|
_FakeImapClient.instances.append(self)
|
||||||
|
|
||||||
|
def starttls(self, *args, **kwargs):
|
||||||
|
self.calls.append("starttls")
|
||||||
|
|
||||||
|
def login(self, username, password):
|
||||||
|
self.login_args = (username, password)
|
||||||
|
self.calls.append("login")
|
||||||
|
|
||||||
|
def append(self, folder, flags, date_time, message_bytes):
|
||||||
|
self.appended.append((folder, flags, date_time, message_bytes))
|
||||||
|
self.calls.append("append")
|
||||||
|
return self.append_result
|
||||||
|
|
||||||
|
def logout(self):
|
||||||
|
self.calls.append("logout")
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _reset_fakes():
|
||||||
|
_FakeSmtpClient.instances.clear()
|
||||||
|
_FakeImapClient.instances.clear()
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_smtp(monkeypatch):
|
||||||
|
monkeypatch.setattr(mail_delivery.smtplib, "SMTP", _FakeSmtpClient)
|
||||||
|
monkeypatch.setattr(mail_delivery.smtplib, "SMTP_SSL", _FakeSmtpClient)
|
||||||
|
|
||||||
|
|
||||||
|
def _patch_imap(monkeypatch):
|
||||||
|
monkeypatch.setattr(mail_delivery.imaplib, "IMAP4", _FakeImapClient)
|
||||||
|
monkeypatch.setattr(mail_delivery.imaplib, "IMAP4_SSL", _FakeImapClient)
|
||||||
|
|
||||||
|
|
||||||
|
SMTP_SETTINGS = {
|
||||||
|
"smtp_host": "mail.example.org",
|
||||||
|
"smtp_port": 587,
|
||||||
|
"smtp_security": "starttls",
|
||||||
|
"smtp_username": "board@example.org",
|
||||||
|
"smtp_password": "secret",
|
||||||
|
}
|
||||||
|
|
||||||
|
IMAP_SETTINGS = {
|
||||||
|
"imap_host": "mail.example.org",
|
||||||
|
"imap_port": 993,
|
||||||
|
"imap_security": "ssl",
|
||||||
|
"imap_username": "board@example.org",
|
||||||
|
"imap_password": "secret",
|
||||||
|
}
|
||||||
|
|
||||||
|
MESSAGE = (
|
||||||
|
b"From: Board <board@example.org>\r\n"
|
||||||
|
b"To: member@example.org\r\n"
|
||||||
|
b"Subject: Test\r\n"
|
||||||
|
b"\r\n"
|
||||||
|
b"Hello\r\n"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_send_via_smtp_logs_in_and_sends_reusing_one_connection(monkeypatch) -> None:
|
||||||
|
_patch_smtp(monkeypatch)
|
||||||
|
with mail_delivery.smtp_session(SMTP_SETTINGS) as client:
|
||||||
|
mail_delivery.send_via_smtp(client, MESSAGE)
|
||||||
|
mail_delivery.send_via_smtp(client, MESSAGE)
|
||||||
|
|
||||||
|
assert len(_FakeSmtpClient.instances) == 1
|
||||||
|
fake = _FakeSmtpClient.instances[0]
|
||||||
|
assert fake.host == "mail.example.org"
|
||||||
|
assert fake.port == 587
|
||||||
|
assert fake.login_args == ("board@example.org", "secret")
|
||||||
|
assert fake.calls == ["starttls", "login", "send_message", "send_message", "quit"]
|
||||||
|
assert len(fake.sent_messages) == 2
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_to_imap_drafts_uses_configured_folder(monkeypatch) -> None:
|
||||||
|
_patch_imap(monkeypatch)
|
||||||
|
with mail_delivery.imap_session(IMAP_SETTINGS) as client:
|
||||||
|
mail_delivery.append_to_imap_drafts(client, MESSAGE, folder="INBOX.Entwürfe")
|
||||||
|
|
||||||
|
fake = _FakeImapClient.instances[0]
|
||||||
|
assert fake.login_args == ("board@example.org", "secret")
|
||||||
|
assert fake.appended == [("INBOX.Entwürfe", r"(\Draft)", None, MESSAGE)]
|
||||||
|
assert fake.calls == ["login", "append", "logout"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_append_to_imap_drafts_raises_on_rejected_status(monkeypatch) -> None:
|
||||||
|
_patch_imap(monkeypatch)
|
||||||
|
with mail_delivery.imap_session(IMAP_SETTINGS) as client:
|
||||||
|
client.append_result = ("NO", [b"Mailbox does not exist"])
|
||||||
|
with pytest.raises(RepositoryError, match="Mailbox does not exist"):
|
||||||
|
mail_delivery.append_to_imap_drafts(client, MESSAGE, folder="Missing")
|
||||||
|
|
||||||
|
|
||||||
|
def test_smtp_session_wraps_connection_errors(monkeypatch) -> None:
|
||||||
|
def _boom(*args, **kwargs):
|
||||||
|
raise OSError("connection refused")
|
||||||
|
|
||||||
|
monkeypatch.setattr(mail_delivery.smtplib, "SMTP", _boom)
|
||||||
|
with pytest.raises(RepositoryError, match="SMTP-Verbindung fehlgeschlagen"):
|
||||||
|
with mail_delivery.smtp_session({**SMTP_SETTINGS, "smtp_security": "none"}):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_smtp_session_requires_host() -> None:
|
||||||
|
with pytest.raises(RepositoryError, match="Kein SMTP-Server konfiguriert"):
|
||||||
|
with mail_delivery.smtp_session({}):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_imap_session_requires_host() -> None:
|
||||||
|
with pytest.raises(RepositoryError, match="Kein IMAP-Server konfiguriert"):
|
||||||
|
with mail_delivery.imap_session({}):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_test_smtp_connection_succeeds_and_closes(monkeypatch) -> None:
|
||||||
|
_patch_smtp(monkeypatch)
|
||||||
|
mail_delivery.test_smtp_connection(SMTP_SETTINGS)
|
||||||
|
assert _FakeSmtpClient.instances[0].calls == ["starttls", "login", "quit"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_test_imap_connection_succeeds_and_closes(monkeypatch) -> None:
|
||||||
|
_patch_imap(monkeypatch)
|
||||||
|
mail_delivery.test_imap_connection(IMAP_SETTINGS)
|
||||||
|
assert _FakeImapClient.instances[0].calls == ["login", "logout"]
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
from contextlib import contextmanager
|
||||||
from email.parser import BytesParser
|
from email.parser import BytesParser
|
||||||
from email.policy import default
|
from email.policy import default
|
||||||
|
|
||||||
@@ -35,6 +36,7 @@ def test_reminder_mail_is_archived_and_marks_reminder_sent(tmp_path):
|
|||||||
member.member_id,
|
member.member_id,
|
||||||
"claim-1",
|
"claim-1",
|
||||||
reminder["reminder_id"],
|
reminder["reminder_id"],
|
||||||
|
delivery_mode="local",
|
||||||
output_path=export_path,
|
output_path=export_path,
|
||||||
sender_name="Verwaltung C3MA",
|
sender_name="Verwaltung C3MA",
|
||||||
sender_email="verwaltung@example.org",
|
sender_email="verwaltung@example.org",
|
||||||
@@ -72,6 +74,7 @@ def test_reminder_mail_includes_fee_in_open_balance(tmp_path):
|
|||||||
member.member_id,
|
member.member_id,
|
||||||
"claim-1",
|
"claim-1",
|
||||||
reminder["reminder_id"],
|
reminder["reminder_id"],
|
||||||
|
delivery_mode="local",
|
||||||
output_path=tmp_path / "Mahnung.eml",
|
output_path=tmp_path / "Mahnung.eml",
|
||||||
sender_name="Verwaltung C3MA",
|
sender_name="Verwaltung C3MA",
|
||||||
sender_email="verwaltung@example.org",
|
sender_email="verwaltung@example.org",
|
||||||
@@ -81,3 +84,56 @@ def test_reminder_mail_includes_fee_in_open_balance(tmp_path):
|
|||||||
message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes())
|
message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes())
|
||||||
assert "Offener Betrag: 105.00 Euro" in message.get_content()
|
assert "Offener Betrag: 105.00 Euro" in message.get_content()
|
||||||
assert "Mahngebühr: 5.00 Euro" in message.get_content()
|
assert "Mahngebühr: 5.00 Euro" in message.get_content()
|
||||||
|
|
||||||
|
|
||||||
|
def test_reminder_mail_send_mode_delivers_via_smtp_without_local_file(tmp_path, monkeypatch):
|
||||||
|
import ccma.services.reminder_mail as reminder_mail_module
|
||||||
|
|
||||||
|
repository, member = _overdue_claim_repository(tmp_path / "store")
|
||||||
|
member.email = "reminder@example.org"
|
||||||
|
repository.save_member(member)
|
||||||
|
repository.save_email_settings(
|
||||||
|
delivery_mode="send",
|
||||||
|
smtp_host="mail.example.org",
|
||||||
|
smtp_port=587,
|
||||||
|
smtp_security="none",
|
||||||
|
smtp_username="",
|
||||||
|
smtp_password="",
|
||||||
|
imap_host="",
|
||||||
|
imap_port=993,
|
||||||
|
imap_security="ssl",
|
||||||
|
imap_username="",
|
||||||
|
imap_password="",
|
||||||
|
imap_drafts_folder="",
|
||||||
|
)
|
||||||
|
reminder = repository.create_reminder_draft(
|
||||||
|
member.member_id,
|
||||||
|
"claim-1",
|
||||||
|
level=1,
|
||||||
|
name="Zahlungserinnerung",
|
||||||
|
payment_deadline_days=14,
|
||||||
|
)
|
||||||
|
|
||||||
|
sent = []
|
||||||
|
fake_smtp_session = contextmanager(lambda settings: iter([object()]))
|
||||||
|
monkeypatch.setattr(reminder_mail_module, "smtp_session", fake_smtp_session)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
reminder_mail_module, "send_via_smtp", lambda client, content: sent.append(content)
|
||||||
|
)
|
||||||
|
|
||||||
|
generated = generate_and_send_reminder_mail(
|
||||||
|
repository,
|
||||||
|
member.member_id,
|
||||||
|
"claim-1",
|
||||||
|
reminder["reminder_id"],
|
||||||
|
delivery_mode="send",
|
||||||
|
sender_name="Verwaltung C3MA",
|
||||||
|
sender_email="verwaltung@example.org",
|
||||||
|
signature="Der Vorstand",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert generated.export_path is None
|
||||||
|
assert generated.archive_path.is_file()
|
||||||
|
assert len(sent) == 1
|
||||||
|
data = repository.get_contributions(member.member_id)
|
||||||
|
assert data.reminders[0]["status"] == "sent"
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import xml.etree.ElementTree as ET
|
import xml.etree.ElementTree as ET
|
||||||
|
from contextlib import contextmanager
|
||||||
from datetime import UTC, date, datetime
|
from datetime import UTC, date, datetime
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
from email.parser import BytesParser
|
from email.parser import BytesParser
|
||||||
@@ -214,6 +215,7 @@ def test_generated_mail_is_exported_archived_and_logged(tmp_path):
|
|||||||
repository,
|
repository,
|
||||||
debits,
|
debits,
|
||||||
collection_date=date(2026, 8, 3),
|
collection_date=date(2026, 8, 3),
|
||||||
|
delivery_mode="local",
|
||||||
output_directory=tmp_path / "mail-export",
|
output_directory=tmp_path / "mail-export",
|
||||||
sender_name="Verwaltung C3MA",
|
sender_name="Verwaltung C3MA",
|
||||||
sender_email="verwaltung@example.org",
|
sender_email="verwaltung@example.org",
|
||||||
@@ -227,3 +229,64 @@ def test_generated_mail_is_exported_archived_and_logged(tmp_path):
|
|||||||
event = repository.get_events(member.member_id)[-1]
|
event = repository.get_events(member.member_id)[-1]
|
||||||
assert event.event_type == "sepa_notification_generated"
|
assert event.event_type == "sepa_notification_generated"
|
||||||
assert event.references["document"].startswith("documents/SEPA/")
|
assert event.references["document"].startswith("documents/SEPA/")
|
||||||
|
|
||||||
|
|
||||||
|
def test_generated_mail_send_mode_reuses_one_smtp_connection_for_batch(tmp_path, monkeypatch):
|
||||||
|
import ccma.services.sepa_mail as sepa_mail_module
|
||||||
|
|
||||||
|
repository, member = _repository(tmp_path)
|
||||||
|
organization = repository.get_configuration()["organization"]
|
||||||
|
organization.update(
|
||||||
|
{
|
||||||
|
"name": "Chaos Computer Club Mannheim e.V.",
|
||||||
|
"email": "verwaltung@example.org",
|
||||||
|
"iban": "DE89370400440532013000",
|
||||||
|
"bic": "COBADEFFXXX",
|
||||||
|
"creditor_id": "DE98ZZZ09999999999",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
repository.save_organization(organization)
|
||||||
|
repository.save_email_settings(
|
||||||
|
delivery_mode="send",
|
||||||
|
smtp_host="mail.example.org",
|
||||||
|
smtp_port=587,
|
||||||
|
smtp_security="none",
|
||||||
|
smtp_username="",
|
||||||
|
smtp_password="",
|
||||||
|
imap_host="",
|
||||||
|
imap_port=993,
|
||||||
|
imap_security="ssl",
|
||||||
|
imap_username="",
|
||||||
|
imap_password="",
|
||||||
|
imap_drafts_folder="",
|
||||||
|
)
|
||||||
|
debits, _warnings = pending_direct_debits(repository, due_until=date(2026, 12, 31))
|
||||||
|
|
||||||
|
connections_opened = []
|
||||||
|
|
||||||
|
@contextmanager
|
||||||
|
def fake_smtp_session(settings):
|
||||||
|
connections_opened.append(settings)
|
||||||
|
yield object()
|
||||||
|
|
||||||
|
sent = []
|
||||||
|
monkeypatch.setattr(sepa_mail_module, "smtp_session", fake_smtp_session)
|
||||||
|
monkeypatch.setattr(
|
||||||
|
sepa_mail_module, "send_via_smtp", lambda client, content: sent.append(content)
|
||||||
|
)
|
||||||
|
|
||||||
|
generated, warnings = generate_debit_mails(
|
||||||
|
repository,
|
||||||
|
debits,
|
||||||
|
collection_date=date(2026, 8, 3),
|
||||||
|
delivery_mode="send",
|
||||||
|
sender_name="Verwaltung C3MA",
|
||||||
|
sender_email="verwaltung@example.org",
|
||||||
|
signature="Der Vorstand",
|
||||||
|
)
|
||||||
|
|
||||||
|
assert warnings == []
|
||||||
|
assert generated[0].export_path is None
|
||||||
|
assert generated[0].archive_path.is_file()
|
||||||
|
assert len(connections_opened) == 1, "one connection should be reused for the whole batch"
|
||||||
|
assert len(sent) == len(debits)
|
||||||
|
|||||||
Reference in New Issue
Block a user