"""
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''
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)