mirror of
https://git.hiabuto.net/C3MA/CCMA.git
synced 2026-09-06 03:20:49 +02:00
validate_mail_template() only looked at placeholder names, and "{{#claims}}" is
not a placeholder -- so an unclosed block or a stray "{{/claims}}" passed the
check and rendered as itself: the member would read the marker in their mail.
Repeat markers are now checked structurally: every one names a block the template
actually has, openers and closers pair up in order, blocks do not nest (the
renderer does not support it either), and the subject takes no markers at all.
Each case explains what is wrong and what is missing.
The same check runs before sending, not just before saving: the templates are
plain files in the store and can be edited outside CCMA, where refusing to send
beats mailing a marker. Saving several edited templates now validates all of
them before writing the first, so a mistake in one no longer leaves the others
half-saved.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
296 lines
10 KiB
Python
296 lines
10 KiB
Python
from contextlib import contextmanager
|
|
from email.parser import BytesParser
|
|
from email.policy import default
|
|
|
|
import pytest
|
|
|
|
from ccma.services.welcome_mail import generate_and_send_welcome_mail, open_claims
|
|
from ccma.storage.repository import MemberRepository, RepositoryError
|
|
|
|
|
|
def _new_member_repository(tmp_path):
|
|
repository = MemberRepository(tmp_path)
|
|
repository.initialize()
|
|
organization = repository.get_configuration()["organization"]
|
|
organization.update(
|
|
{
|
|
"name": "Chaos Computer Club Mannheim e.V.",
|
|
"email": "verwaltung@example.org",
|
|
"iban": "DE98670505050038907751",
|
|
"bic": "MANSDE66XXX",
|
|
}
|
|
)
|
|
repository.save_organization(organization)
|
|
member = repository.create_member(
|
|
first_name="Ada", last_name="Lovelace", birth_date="1990-01-01"
|
|
)
|
|
member.email = "ada@example.org"
|
|
member.accepted_at = "2026-08-20"
|
|
repository.save_member(member)
|
|
repository.create_manual_claim(
|
|
member.member_id, title="Aufnahmegebühr", amount="15.00", due_date="2026-09-17"
|
|
)
|
|
repository.create_manual_claim(
|
|
member.member_id,
|
|
title="Mitgliedsbeitrag 2. Halbjahr 2026",
|
|
amount="60.00",
|
|
due_date="2026-09-17",
|
|
)
|
|
return repository, member
|
|
|
|
|
|
def test_welcome_mail_lists_open_claims_and_bank_details(tmp_path):
|
|
repository, member = _new_member_repository(tmp_path)
|
|
export_path = tmp_path / "Willkommen.eml"
|
|
|
|
generated = generate_and_send_welcome_mail(
|
|
repository,
|
|
member.member_id,
|
|
delivery_mode="local",
|
|
output_path=export_path,
|
|
sender_name="Verwaltung C3MA",
|
|
sender_email="verwaltung@example.org",
|
|
signature="Der Vorstand",
|
|
)
|
|
|
|
message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes())
|
|
content = message.get_content()
|
|
assert message["To"] == "ada@example.org"
|
|
assert "Willkommen" in message["Subject"]
|
|
assert "Aufnahmegebühr (fällig 17.09.2026): 15.00 Euro" in content
|
|
assert "Mitgliedsbeitrag 2. Halbjahr 2026 (fällig 17.09.2026): 60.00 Euro" in content
|
|
assert "Gesamtbetrag: 75.00 Euro" in content
|
|
assert "DE98670505050038907751" in content
|
|
assert content.rstrip().endswith("Der Vorstand")
|
|
# No "Mitglied seit" is stored yet -- a member accepted in August starts on 1 September.
|
|
assert "Ab dem 01.09.2026 bist du offiziell Mitglied" in content
|
|
|
|
|
|
def test_welcome_mail_is_archived_and_logged(tmp_path):
|
|
repository, member = _new_member_repository(tmp_path)
|
|
|
|
generated = generate_and_send_welcome_mail(
|
|
repository,
|
|
member.member_id,
|
|
delivery_mode="local",
|
|
output_path=tmp_path / "Willkommen.eml",
|
|
sender_name="Verwaltung C3MA",
|
|
sender_email="verwaltung@example.org",
|
|
signature="Der Vorstand",
|
|
)
|
|
|
|
assert generated.archive_path.parent.name == "Willkommen"
|
|
assert generated.archive_path.read_bytes() == generated.export_path.read_bytes()
|
|
event = repository.get_events(member.member_id)[-1]
|
|
assert event.event_type == "welcome_email_sent"
|
|
assert event.data["recipient"] == "ada@example.org"
|
|
assert len(event.data["claim_ids"]) == 2
|
|
|
|
|
|
def test_only_selected_claims_are_billed(tmp_path):
|
|
repository, member = _new_member_repository(tmp_path)
|
|
admission = next(
|
|
claim for claim in open_claims(repository, member.member_id)
|
|
if claim["title"] == "Aufnahmegebühr"
|
|
)
|
|
|
|
generated = generate_and_send_welcome_mail(
|
|
repository,
|
|
member.member_id,
|
|
claim_ids=[admission["claim_id"]],
|
|
delivery_mode="local",
|
|
output_path=tmp_path / "Willkommen.eml",
|
|
sender_name="Verwaltung C3MA",
|
|
sender_email="verwaltung@example.org",
|
|
signature="Der Vorstand",
|
|
)
|
|
|
|
content = BytesParser(policy=default).parsebytes(
|
|
generated.export_path.read_bytes()
|
|
).get_content()
|
|
assert "Aufnahmegebühr" in content
|
|
assert "Mitgliedsbeitrag 2. Halbjahr 2026" not in content
|
|
assert "Gesamtbetrag: 15.00 Euro" in content
|
|
|
|
|
|
def test_members_with_mandate_are_told_the_money_is_collected(tmp_path):
|
|
repository, member = _new_member_repository(tmp_path)
|
|
member.account_holder = "Ada Lovelace"
|
|
member.iban = "DE98670505050038907751"
|
|
member.mandate_reference = "MANDAT-42"
|
|
member.mandate_signed_at = "2026-08-20"
|
|
member.mandate_active = True
|
|
repository.save_member(member)
|
|
|
|
generated = generate_and_send_welcome_mail(
|
|
repository,
|
|
member.member_id,
|
|
delivery_mode="local",
|
|
output_path=tmp_path / "Willkommen.eml",
|
|
sender_name="Verwaltung C3MA",
|
|
sender_email="verwaltung@example.org",
|
|
signature="Der Vorstand",
|
|
)
|
|
|
|
content = BytesParser(policy=default).parsebytes(
|
|
generated.export_path.read_bytes()
|
|
).get_content()
|
|
assert "SEPA-Lastschriftmandat erteilt (Mandatsreferenz MANDAT-42)" in content
|
|
assert "Bitte überweise" not in content
|
|
|
|
|
|
def test_edited_template_is_used_for_the_mail(tmp_path):
|
|
repository, member = _new_member_repository(tmp_path)
|
|
repository.save_mail_template(
|
|
"welcome",
|
|
subject="Servus {{member.first_name}}",
|
|
body="Du schuldest uns {{claims.total}} Euro.\n{{#claims}}* {{claim.title}}{{/claims}}",
|
|
)
|
|
|
|
generated = generate_and_send_welcome_mail(
|
|
repository,
|
|
member.member_id,
|
|
delivery_mode="local",
|
|
output_path=tmp_path / "Willkommen.eml",
|
|
sender_name="Verwaltung C3MA",
|
|
sender_email="verwaltung@example.org",
|
|
signature="Der Vorstand",
|
|
)
|
|
|
|
message = BytesParser(policy=default).parsebytes(generated.export_path.read_bytes())
|
|
assert message["Subject"] == "Servus Ada"
|
|
assert message.get_content().replace("\r\n", "\n").strip() == (
|
|
"Du schuldest uns 75.00 Euro.\n* Aufnahmegebühr\n* Mitgliedsbeitrag 2. Halbjahr 2026"
|
|
)
|
|
|
|
|
|
def test_send_mode_delivers_via_smtp_without_local_file(tmp_path, monkeypatch):
|
|
import ccma.services.welcome_mail as welcome_mail_module
|
|
|
|
repository, member = _new_member_repository(tmp_path)
|
|
repository.save_email_settings(
|
|
delivery_mode="send",
|
|
smtp_host="smtp.example.org",
|
|
smtp_port=587,
|
|
smtp_security="starttls",
|
|
smtp_username="verwaltung",
|
|
smtp_password="secret",
|
|
imap_host="",
|
|
imap_port=993,
|
|
imap_security="ssl",
|
|
imap_username="",
|
|
imap_password="",
|
|
imap_drafts_folder="INBOX.Entwürfe",
|
|
)
|
|
sent = []
|
|
monkeypatch.setattr(
|
|
welcome_mail_module, "smtp_session", contextmanager(lambda settings: iter(["client"]))
|
|
)
|
|
monkeypatch.setattr(
|
|
welcome_mail_module, "send_via_smtp", lambda client, content: sent.append(content)
|
|
)
|
|
|
|
generated = generate_and_send_welcome_mail(
|
|
repository,
|
|
member.member_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.read_bytes() == sent[0]
|
|
assert repository.get_events(member.member_id)[-1].data["delivery_mode"] == "send"
|
|
|
|
|
|
def test_member_without_email_is_refused(tmp_path):
|
|
repository, member = _new_member_repository(tmp_path)
|
|
member.email = ""
|
|
repository.save_member(member)
|
|
|
|
with pytest.raises(RepositoryError, match="E-Mail-Adresse"):
|
|
generate_and_send_welcome_mail(
|
|
repository,
|
|
member.member_id,
|
|
delivery_mode="local",
|
|
output_path=tmp_path / "Willkommen.eml",
|
|
sender_name="Verwaltung C3MA",
|
|
sender_email="verwaltung@example.org",
|
|
signature="Der Vorstand",
|
|
)
|
|
|
|
|
|
def test_archiving_failure_after_smtp_success_still_logs_the_sent_mail(tmp_path, monkeypatch):
|
|
import ccma.services.welcome_mail as welcome_mail_module
|
|
|
|
repository, member = _new_member_repository(tmp_path)
|
|
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="",
|
|
)
|
|
monkeypatch.setattr(
|
|
welcome_mail_module, "smtp_session", contextmanager(lambda settings: iter(["client"]))
|
|
)
|
|
monkeypatch.setattr(welcome_mail_module, "send_via_smtp", lambda client, content: None)
|
|
real_replace = welcome_mail_module.os.replace
|
|
|
|
def _flaky_replace(src, dst):
|
|
# Only the final move into the member file fails -- the mail is out by then.
|
|
if str(dst).endswith(".eml") and "Willkommen" in str(dst):
|
|
raise OSError("disk full")
|
|
return real_replace(src, dst)
|
|
|
|
monkeypatch.setattr(welcome_mail_module.os, "replace", _flaky_replace)
|
|
|
|
with pytest.raises(RepositoryError, match="zugestellt"):
|
|
generate_and_send_welcome_mail(
|
|
repository,
|
|
member.member_id,
|
|
delivery_mode="send",
|
|
sender_name="Verwaltung C3MA",
|
|
sender_email="verwaltung@example.org",
|
|
signature="Der Vorstand",
|
|
)
|
|
|
|
event = repository.get_events(member.member_id)[-1]
|
|
assert event.event_type == "welcome_email_sent"
|
|
assert event.data["archive_error"]
|
|
assert "document" not in event.references
|
|
|
|
|
|
def test_a_template_broken_outside_ccma_stops_the_send(tmp_path):
|
|
repository, member = _new_member_repository(tmp_path)
|
|
# The templates are plain files in the store, so they can be edited (and broken)
|
|
# with any text editor -- the send has to notice instead of mailing the marker.
|
|
(repository.mail_templates_root / "willkommen.txt").write_text(
|
|
"Betreff: Willkommen\n\nHallo,\n{{#claims}}\n{{claim.title}}\n",
|
|
encoding="utf-8",
|
|
)
|
|
|
|
with pytest.raises(RepositoryError, match="wird nicht geschlossen"):
|
|
generate_and_send_welcome_mail(
|
|
repository,
|
|
member.member_id,
|
|
delivery_mode="local",
|
|
output_path=tmp_path / "Willkommen.eml",
|
|
sender_name="Verwaltung C3MA",
|
|
sender_email="verwaltung@example.org",
|
|
signature="Der Vorstand",
|
|
)
|
|
|
|
assert not (tmp_path / "Willkommen.eml").exists()
|
|
archive = repository.members_root / member.member_id / "files" / "documents" / "Willkommen"
|
|
assert not archive.exists()
|
|
assert repository.get_events(member.member_id)[-1].event_type != "welcome_email_sent"
|