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
+48
View File
@@ -10,6 +10,10 @@ PLACEHOLDER_PATTERN = re.compile(r"\{\{\s*([a-z][a-z0-9_.]*)\s*\}\}", re.IGNOREC
# A repeat block either wraps a single line ("{{#claims}}- {{claim.title}}{{/claims}}")
# or spans several lines with the markers on their own lines; both render one copy of
# the block per entry.
# Matches a single repeat marker, opening or closing. The placeholder pattern
# deliberately does not, so unbalanced markers need a check of their own -- they
# would otherwise survive validation and end up verbatim in a sent mail.
MARKER_PATTERN = re.compile(r"\{\{\s*([#/])\s*([a-z][a-z0-9_.]*)\s*\}\}", re.IGNORECASE)
BLOCK_PATTERN = re.compile(
r"\{\{\s*#\s*([a-z][a-z0-9_.]*)\s*\}\}[ \t]*\n?(.*?)\{\{\s*/\s*\1\s*\}\}[ \t]*(\n?)",
re.IGNORECASE | re.DOTALL,
@@ -254,10 +258,54 @@ def render_mail_template(
return MailTemplate(subject.strip(), _render_text(body, values))
def validate_block_markers(key: str, subject: str, body: str) -> None:
"""Checks that every {{#block}} is closed by its own {{/block}} and that no
stray marker is left over. An unbalanced marker is not a placeholder, so it
renders as itself -- the recipient would read "{{#claims}}" in their mail."""
spec = template_spec(key)
known = {block.name.casefold(): block.name for block in spec.blocks}
for match in MARKER_PATTERN.finditer(subject):
raise MailTemplateError(
f"Wiederholungsblöcke sind im Betreff nicht möglich: {match.group(0)}"
)
open_marker: tuple[str, str] | None = None
for match in MARKER_PATTERN.finditer(body):
kind, name = match.group(1), match.group(2)
if name.casefold() not in known:
raise MailTemplateError(
f"Unbekannter Wiederholungsblock: {match.group(0)}. Verfügbar: "
+ (", ".join(f"{{{{#{value}}}}}" for value in known.values()) or "keine")
)
if kind == "#":
if open_marker is not None:
raise MailTemplateError(
f"Wiederholungsblöcke können nicht ineinander verschachtelt werden: "
f"{{{{#{open_marker[1]}}}}} ist noch offen, als {match.group(0)} beginnt."
)
open_marker = (kind, name)
continue
if open_marker is None:
raise MailTemplateError(
f"{match.group(0)} steht ohne zugehöriges {{{{#{name}}}}} in der Vorlage."
)
if open_marker[1].casefold() != name.casefold():
raise MailTemplateError(
f"{{{{#{open_marker[1]}}}}} wird durch {match.group(0)} geschlossen. "
f"Erwartet wird {{{{/{open_marker[1]}}}}}."
)
open_marker = None
if open_marker is not None:
raise MailTemplateError(
f"{{{{#{open_marker[1]}}}}} wird nicht geschlossen. Es fehlt "
f"{{{{/{open_marker[1]}}}}}."
)
def validate_mail_template(key: str, subject: str, body: str) -> None:
"""Rejects placeholders the mail service will never supply, so a typo surfaces
while editing the template instead of during a send run."""
spec = template_spec(key)
validate_block_markers(key, subject, body)
known = {name for name, _help in spec.placeholders}
block_names = {block.name for block in spec.blocks}
unknown: set[str] = set()