""" Per-recipient message rendering: {Field} placeholders, {Field|fallback}, list fields as bullets or an inline "A, B and C", and {if Field has "x"}...{else}...{/if} conditional blocks. One engine serves both the real send and the Campaigns page's preview, so what is previewed is what is sent. Row values are HTML-escaped when rendering HTML (a value containing "<" must not become markup in someone's inbox); the subject is rendered as plain text and is not escaped. A template that cannot be rendered safely (unclosed {if}, stray {else}, a condition that does not parse) is reported by validate_template so a send is refused up front instead of going out half-rendered to every recipient. """ from __future__ import annotations import html as html_lib import re from dataclasses import dataclass, field LIST_SPLIT_MIXED = re.compile(r"[;,|\n]") _BLOCK_TOKEN = re.compile(r"\{(?:if\s+([^{}]*)|(else)|(/if))\}") _PLACEHOLDER = re.compile(r"\{([A-Za-z0-9_][A-Za-z0-9_ ]*?)(?:\|([^{}]*))?\}") _PARAGRAPH_TOKEN = re.compile(r"]*)?>\s*(\{(?:if\s[^{}]*|else|/if)\})\s*

", re.IGNORECASE) _PARAGRAPH_LIST = re.compile(r"]*)?>\s*\{([A-Za-z0-9_][A-Za-z0-9_ ]*?)(?:\|(bullets|inline))?\}\s*

", re.IGNORECASE) _CONDITION = re.compile( r"""^(?P[A-Za-z0-9_][A-Za-z0-9_ ]*?)\s+ (?Pis\ not\ empty|is\ empty|is\ true|is\ false|does\ not\ contain|is\ not|is|has\ not|lacks|has|contains) (?:\s+["“”'](?P.*?)["“”'])?\s*$""", re.VERBOSE | re.IGNORECASE, ) _NEEDS_VALUE = {"is", "is not", "has", "has not", "lacks", "contains", "does not contain"} _UL_STYLE = "margin:0 0 14px;padding-left:22px;" _LI_STYLE = "margin:0 0 4px;" class TemplateError(ValueError): pass @dataclass class _Cond: field: str op: str value: str @dataclass class _If: cond: _Cond then: list = field(default_factory=list) other: list = field(default_factory=list) def split_items(text: str, delimiter: str | None) -> list[str]: """Splits a list-style cell into trimmed, non-empty items. delimiter "mixed" accepts any of ; , | or a newline in the same cell (people are inconsistent), anything else is literal.""" if not text: return [] if delimiter == "mixed": parts = LIST_SPLIT_MIXED.split(text) else: parts = text.split(delimiter or ";") return [p.strip() for p in parts if p.strip()] def _parse_condition(raw: str) -> _Cond: m = _CONDITION.match(html_lib.unescape(raw).strip()) if not m: raise TemplateError(f'Could not read the condition "{{if {raw.strip()}}}"') op = re.sub(r"\s+", " ", m.group("op").lower()) value = m.group("value") if op in _NEEDS_VALUE and value is None: raise TemplateError(f'The condition "{{if {raw.strip()}}}" needs a value in quotes') return _Cond(m.group("field").strip(), op, (value or "").strip()) def _parse(template: str) -> list: """Template text -> tree of str and _If nodes. Raises TemplateError on unbalanced blocks.""" root: list = [] stack: list[tuple[_If, bool]] = [] # (node, currently_in_else) current = root pos = 0 for m in _BLOCK_TOKEN.finditer(template): if m.start() > pos: current.append(template[pos:m.start()]) pos = m.end() if m.group(1) is not None: node = _If(_parse_condition(m.group(1))) current.append(node) stack.append((node, False)) current = node.then elif m.group(2): if not stack or stack[-1][1]: raise TemplateError("Found {else} without a matching {if ...}") node, _ = stack[-1] stack[-1] = (node, True) current = node.other else: if not stack: raise TemplateError("Found {/if} without a matching {if ...}") stack.pop() current = stack[-1][0].other if stack and stack[-1][1] else (stack[-1][0].then if stack else root) if stack: raise TemplateError("An {if ...} block is missing its {/if}") if pos < len(template): current.append(template[pos:]) return root def _column_defs(columns: list[dict] | None) -> dict[str, dict]: return {c["name"]: c for c in (columns or [])} def _evaluate(cond: _Cond, row: dict, cols: dict[str, dict]) -> bool: col = cols.get(cond.field) if col is None and cols: return False # a condition on a field the audience does not have never matches text = str(row.get(cond.field, "") or "") col_type = (col or {}).get("type", "text") needle = cond.value.strip().lower() op = cond.op if op == "is empty": return text.strip() == "" if op == "is not empty": return text.strip() != "" if op in ("is true", "is false"): truthy = text.strip().lower() in {"true", "yes", "y", "1"} return truthy if op == "is true" else not truthy if col_type == "tag": items = {i.lower() for i in split_items(text, col.get("delimiter"))} if op in ("has", "is"): return needle in items if op in ("lacks", "has not", "is not"): return needle not in items hay = text.strip().lower() if op == "is": return hay == needle if op == "is not": return hay != needle if op in ("has", "lacks", "has not"): # On a plain column "has" means the whole word/phrase appears, so "yes" is not # matched by "yesterday". found = re.search(r"(? str: if len(items) <= 1: return "".join(items) return ", ".join(items[:-1]) + " and " + items[-1] def _bullets(items: list[str], escape: bool) -> str: esc = html_lib.escape if escape else (lambda s: s) lis = "".join(f'
  • {esc(i)}
  • ' for i in items) return f'
      {lis}
    ' def _render_text(text: str, row: dict, cols: dict[str, dict], html: bool) -> str: esc = html_lib.escape if html else (lambda s: s) if html: # A list field alone in its paragraph becomes a real bullet list (replacing the paragraph). def paragraph_list(m: re.Match) -> str: name, mode = m.group(1).strip(), (m.group(2) or "").lower() col = cols.get(name) if not col or col.get("type") != "tag" or mode == "inline": return m.group(0) items = split_items(str(row.get(name, "") or ""), col.get("delimiter")) return _bullets(items, True) if items else "" text = _PARAGRAPH_LIST.sub(paragraph_list, text) def placeholder(m: re.Match) -> str: name, option = m.group(1).strip(), m.group(2) col = cols.get(name) raw = str(row.get(name, "") or "") if col and col.get("type") == "tag": items = split_items(raw, col.get("delimiter")) if items: if option and option.lower() == "bullets" and html: return _bullets(items, True) return esc(_inline_join(items)) elif raw.strip(): return esc(raw) if option and option.lower() not in ("bullets", "inline"): return option # fallback text written by the author return "" return _PLACEHOLDER.sub(placeholder, text) def _render(nodes: list, row: dict, cols: dict[str, dict], html: bool) -> str: out = [] for node in nodes: if isinstance(node, str): out.append(_render_text(node, row, cols, html)) else: branch = node.then if _evaluate(node.cond, row, cols) else node.other out.append(_render(branch, row, cols, html)) return "".join(out) def _prepare(template: str) -> str: # {if}/{else}/{/if} typed on their own line become their own

    ; drop that wrapper so the # block leaves no empty paragraphs behind. return _PARAGRAPH_TOKEN.sub(r"\1", template) def validate_template(template: str, columns: list[dict] | None = None) -> list[str]: """Problems that would make the template render wrongly. Empty list means safe to send.""" problems: list[str] = [] try: tree = _parse(_prepare(template)) except TemplateError as exc: return [str(exc)] names = {c["name"] for c in (columns or [])} if names: def walk(nodes: list) -> None: for n in nodes: if isinstance(n, _If): if n.cond.field not in names: problems.append(f'A condition uses the field "{n.cond.field}", which this audience does not have') walk(n.then) walk(n.other) walk(tree) return list(dict.fromkeys(problems)) def render(template: str, row: dict, columns: list[dict] | None = None, html: bool = True) -> str: """Renders one recipient's copy. Raises TemplateError if the template is malformed.""" tree = _parse(_prepare(template) if html else template) return _render(tree, row, _column_defs(columns), html)