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
+103
View File
@@ -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"
+171
View File
@@ -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"]
+56
View File
@@ -1,3 +1,4 @@
from contextlib import contextmanager
from email.parser import BytesParser
from email.policy import default
@@ -35,6 +36,7 @@ def test_reminder_mail_is_archived_and_marks_reminder_sent(tmp_path):
member.member_id,
"claim-1",
reminder["reminder_id"],
delivery_mode="local",
output_path=export_path,
sender_name="Verwaltung C3MA",
sender_email="verwaltung@example.org",
@@ -72,6 +74,7 @@ def test_reminder_mail_includes_fee_in_open_balance(tmp_path):
member.member_id,
"claim-1",
reminder["reminder_id"],
delivery_mode="local",
output_path=tmp_path / "Mahnung.eml",
sender_name="Verwaltung C3MA",
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())
assert "Offener Betrag: 105.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"
+63
View File
@@ -1,4 +1,5 @@
import xml.etree.ElementTree as ET
from contextlib import contextmanager
from datetime import UTC, date, datetime
from decimal import Decimal
from email.parser import BytesParser
@@ -214,6 +215,7 @@ def test_generated_mail_is_exported_archived_and_logged(tmp_path):
repository,
debits,
collection_date=date(2026, 8, 3),
delivery_mode="local",
output_directory=tmp_path / "mail-export",
sender_name="Verwaltung C3MA",
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]
assert event.event_type == "sepa_notification_generated"
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)