Reject unbalanced repeat markers in mail templates

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>
This commit is contained in:
Marcel Peterkau
2026-08-28 21:19:35 +02:00
co-authored by Claude Opus 5
parent d3dbb5e96d
commit c46662561e
6 changed files with 205 additions and 7 deletions
+61
View File
@@ -3,12 +3,15 @@ import pytest
from ccma.domain.mail_templates import (
MAIL_TEMPLATES,
MailTemplate,
MailTemplateBlock,
MailTemplateError,
MailTemplateSpec,
default_mail_template,
parse_mail_template,
placeholder_entries,
render_mail_template,
serialize_mail_template,
validate_block_markers,
validate_mail_template,
)
from ccma.storage.repository import MemberRepository, RepositoryError
@@ -154,3 +157,61 @@ def test_every_block_snippet_is_valid_in_its_own_template() -> None:
for spec in MAIL_TEMPLATES:
for block in spec.blocks:
validate_mail_template(spec.key, "Betreff", block.snippet)
@pytest.mark.parametrize(
("body", "expected"),
[
("Hallo\n{{#claims}}\n{{claim.title}}", "wird nicht geschlossen"),
("Hallo\n{{/claims}}\nGruß", "ohne zugehöriges"),
("{{#claims}}\n{{claim.title}}\n{{/claims}}\n{{/claims}}", "ohne zugehöriges"),
("{{#claims}}\n{{#claims}}\nx\n{{/claims}}\n{{/claims}}", "verschachtelt"),
("{{#members}}\nx\n{{/members}}", "Unbekannter Wiederholungsblock"),
("{{ # claims }}\nx", "wird nicht geschlossen"),
],
)
def test_unbalanced_repeat_markers_are_rejected(body, expected) -> None:
with pytest.raises(MailTemplateError, match=expected):
validate_mail_template("welcome", "Betreff", body)
def test_repeat_markers_in_the_subject_are_rejected() -> None:
with pytest.raises(MailTemplateError, match="Betreff"):
validate_mail_template("welcome", "Beitrag {{#claims}}", "Hallo")
def test_a_template_without_blocks_rejects_every_marker() -> None:
with pytest.raises(MailTemplateError, match="Verfügbar: keine"):
validate_mail_template("sepa", "Betreff", "{{#claims}}\nx\n{{/claims}}")
def test_a_closing_marker_must_match_the_open_one(monkeypatch) -> None:
two_blocks = MailTemplateSpec(
key="two-blocks",
label="Test",
filename="test.txt",
description="",
placeholders=(),
blocks=(
MailTemplateBlock("first", "", (("item.a", ""),), "{{item.a}}"),
MailTemplateBlock("second", "", (("item.b", ""),), "{{item.b}}"),
),
)
monkeypatch.setattr("ccma.domain.mail_templates.MAIL_TEMPLATES", (two_blocks,))
with pytest.raises(MailTemplateError, match="Erwartet wird \\{\\{/first\\}\\}"):
validate_block_markers("two-blocks", "Betreff", "{{#first}}\nx\n{{/second}}")
def test_a_broken_template_is_not_stored(tmp_path) -> None:
repository = MemberRepository(tmp_path)
repository.initialize()
path = repository.mail_templates_root / "willkommen.txt"
before = path.read_text(encoding="utf-8")
with pytest.raises(RepositoryError, match="wird nicht geschlossen"):
repository.save_mail_template(
"welcome", subject="Willkommen", body="Hallo\n{{#claims}}\n{{claim.title}}"
)
assert path.read_text(encoding="utf-8") == before
+40 -1
View File
@@ -8,7 +8,7 @@ tk = pytest.importorskip("tkinter")
from ccma.config import AppConfig # noqa: E402
from ccma.domain.mail_templates import placeholder_entries # noqa: E402
from ccma.services.welcome_mail import generate_and_send_welcome_mail # noqa: E402
from ccma.storage.repository import MemberRepository # noqa: E402
from ccma.storage.repository import MemberRepository, RepositoryError # noqa: E402
# One root for the whole session: the icon library binds its images to the first
@@ -136,3 +136,42 @@ def test_edited_template_survives_saving_and_produces_real_lines_in_the_mail(
).get_content()
assert "Aufnahmegebühr (fällig 17.09.2026): 15.00 Euro" in content
assert "" not in content
def test_the_dialog_refuses_to_save_an_unbalanced_block(tk_root, repository):
stored = repository.mail_templates_root / "willkommen.txt"
before = stored.read_text(encoding="utf-8")
dialog = _open_options(tk_root, repository)
try:
dialog.mail_template_body.delete("1.0", "end")
dialog.mail_template_body.insert("1.0", "Offen sind aktuell:\n{{#claims}}\n{{claim.title}}")
with pytest.raises(RepositoryError, match="wird nicht geschlossen"):
dialog._save_mail_templates()
finally:
dialog.grab_release()
dialog.destroy()
assert stored.read_text(encoding="utf-8") == before
def test_a_broken_template_does_not_half_save_the_others(tk_root, repository):
reminder_file = repository.mail_templates_root / "mahnung.txt"
before = reminder_file.read_text(encoding="utf-8")
dialog = _open_options(tk_root, repository)
try:
# Edit a valid template first, then break the second one.
dialog.mail_template_var.set("Mahnung")
dialog._select_mail_template()
dialog.mail_template_body.insert("end", "\nP.S. Bitte Mitgliedsnummer angeben.")
dialog.mail_template_var.set("Willkommen & Erstrechnung")
dialog._select_mail_template()
dialog.mail_template_body.insert("end", "\n{{#claims}}\n{{claim.title}}")
with pytest.raises(RepositoryError, match="wird nicht geschlossen"):
dialog._save_mail_templates()
finally:
dialog.grab_release()
dialog.destroy()
assert reminder_file.read_text(encoding="utf-8") == before
+26
View File
@@ -267,3 +267,29 @@ def test_archiving_failure_after_smtp_success_still_logs_the_sent_mail(tmp_path,
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"