main
Colby Knox Publish 0.1.0 fb6e12f 11d ago
//! SendGrid (Handlebars) -> Resend template conversion, plus the lint that
//! checks a template against what Resend actually supports.
//!
//! Resend templates only substitute variables: `{{{KEY}}}`. There are no
//! conditionals, loops, or helpers. So every `{{#if x}}` block in a SendGrid
//! template has to be decided ahead of time, one of three ways:
//!
//! - `split`: emit two templates, one with the block and one without, e.g.
//!   invitation-with-notes and invitation-no-notes; the sender picks.
//! - `keep`: always render the block; a missing value is covered by the
//!   variable's fallback instead.
//! - `drop`: never render the block (its `{{else}}` branch, if any, stays).

use std::collections::{BTreeMap, HashMap};

use regex::Regex;
use serde::{Deserialize, Serialize};
use serde_json::Value;

use crate::store::VariableSpec;

pub const RESERVED_KEYS: &[&str] = &[
    "FIRST_NAME",
    "LAST_NAME",
    "EMAIL",
    "UNSUBSCRIBE_URL",
    "RESEND_UNSUBSCRIBE_URL",
    "contact",
    "this",
];
pub const MAX_VARIABLES: usize = 50;
const MAX_SPLIT_VARS: usize = 4;

#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Mode {
    Split,
    Keep,
    Drop,
}

#[derive(Debug)]
enum Node {
    Text(String),
    Var(String),
    If { var: String, negate: bool, then: Vec<Node>, els: Vec<Node> },
}

/// A conditional found in the source, with the mode we'd suggest for it.
#[derive(Debug, Serialize, Clone)]
pub struct Conditional {
    pub var: String,
    pub suggested: Mode,
    /// When the block is `{{#if x}}{{x}}{{else}}TEXT{{/if}}`, TEXT is the
    /// natural Resend fallback for `x`.
    pub fallback_hint: Option<String>,
}

#[derive(Debug, Serialize, Clone)]
pub struct Variant {
    /// Alias suffix, e.g. "-with-notes". Empty when nothing was split.
    pub alias_suffix: String,
    /// Name suffix, e.g. " (with notes)".
    pub name_suffix: String,
    pub html: String,
    pub subject: String,
    /// Variable keys the converted HTML + subject reference, in first-use order.
    pub variables: Vec<String>,
}

#[derive(Debug, Serialize, Clone)]
pub struct Analysis {
    pub conditionals: Vec<Conditional>,
    /// Handlebars we can't express in Resend at all (each/with/helpers).
    /// Left in the output verbatim and flagged so a human rewrites them.
    pub unsupported: Vec<String>,
}

struct Parsed {
    nodes: Vec<Node>,
    unsupported: Vec<String>,
}

fn is_path(s: &str) -> bool {
    !s.is_empty()
        && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '.' || c == '-')
        && !s.starts_with('.')
}

fn parse(src: &str) -> Parsed {
    // Each frame: (var, negate, then, els, in_else). The root frame has var "".
    struct Frame {
        var: String,
        negate: bool,
        then: Vec<Node>,
        els: Vec<Node>,
        in_else: bool,
    }
    let mut stack = vec![Frame { var: String::new(), negate: false, then: vec![], els: vec![], in_else: false }];
    let mut unsupported = Vec::new();
    // Non-if blocks (each/with/...) we pass through verbatim; track their
    // names so their `{{/name}}` isn't mistaken for the end of an if.
    let mut opaque_blocks: Vec<String> = Vec::new();

    fn push(stack: &mut [Frame], node: Node) {
        let top = stack.last_mut().unwrap();
        if top.in_else { top.els.push(node) } else { top.then.push(node) }
    }

    let mut rest = src;
    while let Some(start) = rest.find("{{") {
        if start > 0 {
            push(&mut stack, Node::Text(rest[..start].to_string()));
        }
        let after = &rest[start..];
        let triple = after.starts_with("{{{");
        let close = if triple { "}}}" } else { "}}" };
        let open_len = if triple { 3 } else { 2 };
        let Some(end_rel) = after[open_len..].find(close) else {
            push(&mut stack, Node::Text(after.to_string()));
            rest = "";
            break;
        };
        let raw_tag = &after[..open_len + end_rel + close.len()];
        let inner = after[open_len..open_len + end_rel].trim().trim_matches('~').trim();
        rest = &after[raw_tag.len()..];

        if triple {
            if is_path(inner) {
                push(&mut stack, Node::Var(inner.to_string()));
            } else {
                unsupported.push(raw_tag.to_string());
                push(&mut stack, Node::Text(raw_tag.to_string()));
            }
            continue;
        }
        if inner.starts_with('!') {
            continue; // Handlebars comment
        }
        if let Some(block) = inner.strip_prefix('#') {
            let mut parts = block.split_whitespace();
            let kind = parts.next().unwrap_or("");
            let arg = parts.next().unwrap_or("");
            if (kind == "if" || kind == "unless") && is_path(arg) && parts.next().is_none() {
                stack.push(Frame { var: arg.to_string(), negate: kind == "unless", then: vec![], els: vec![], in_else: false });
            } else {
                unsupported.push(raw_tag.to_string());
                opaque_blocks.push(kind.to_string());
                push(&mut stack, Node::Text(raw_tag.to_string()));
            }
            continue;
        }
        if let Some(kind) = inner.strip_prefix('/') {
            let kind = kind.trim();
            if (kind == "if" || kind == "unless") && stack.len() > 1 {
                let f = stack.pop().unwrap();
                push(&mut stack, Node::If { var: f.var, negate: f.negate, then: f.then, els: f.els });
            } else {
                if opaque_blocks.last().map(|b| b == kind).unwrap_or(false) {
                    opaque_blocks.pop();
                }
                push(&mut stack, Node::Text(raw_tag.to_string()));
            }
            continue;
        }
        if inner == "else" || inner == "^" {
            if stack.len() > 1 && opaque_blocks.is_empty() {
                stack.last_mut().unwrap().in_else = true;
            } else {
                push(&mut stack, Node::Text(raw_tag.to_string()));
            }
            continue;
        }
        if is_path(inner) {
            push(&mut stack, Node::Var(inner.to_string()));
        } else {
            unsupported.push(raw_tag.to_string());
            push(&mut stack, Node::Text(raw_tag.to_string()));
        }
    }
    if !rest.is_empty() {
        push(&mut stack, Node::Text(rest.to_string()));
    }
    // Unclosed ifs: fold them back in so no content is lost.
    while stack.len() > 1 {
        let f = stack.pop().unwrap();
        unsupported.push(format!("unclosed {{{{#if {}}}}}", f.var));
        push(&mut stack, Node::If { var: f.var, negate: f.negate, then: f.then, els: f.els });
    }
    let root = stack.pop().unwrap();
    Parsed { nodes: root.then, unsupported }
}

fn collect_conditionals(nodes: &[Node], out: &mut Vec<Conditional>) {
    for n in nodes {
        if let Node::If { var, negate, then, els } = n {
            let hint = match (then.as_slice(), els.as_slice()) {
                ([Node::Var(v)], [Node::Text(t)]) if v == var && !negate && !t.contains('<') => Some(t.clone()),
                _ => None,
            };
            if let Some(existing) = out.iter_mut().find(|c| &c.var == var) {
                if existing.fallback_hint.is_none() {
                    existing.fallback_hint = hint.clone();
                }
                if hint.is_none() {
                    existing.suggested = Mode::Split;
                }
            } else {
                let suggested = if hint.is_some() { Mode::Keep } else { Mode::Split };
                out.push(Conditional { var: var.clone(), suggested, fallback_hint: hint });
            }
            collect_conditionals(then, out);
            collect_conditionals(els, out);
        }
    }
}

fn render(nodes: &[Node], truthy: &HashMap<String, bool>, out: &mut String) {
    for n in nodes {
        match n {
            Node::Text(t) => out.push_str(t),
            Node::Var(v) => {
                out.push_str("{{{");
                out.push_str(v);
                out.push_str("}}}");
            }
            Node::If { var, negate, then, els } => {
                let on = truthy.get(var).copied().unwrap_or(true) != *negate;
                render(if on { then } else { els }, truthy, out);
            }
        }
    }
}

pub fn analyze(html: &str, subject: &str) -> Analysis {
    let mut conditionals = Vec::new();
    let mut unsupported = Vec::new();
    for src in [subject, html] {
        let p = parse(src);
        collect_conditionals(&p.nodes, &mut conditionals);
        unsupported.extend(p.unsupported);
    }
    Analysis { conditionals, unsupported }
}

/// Converts one SendGrid template into one or more Resend-ready variants.
/// Conditionals not named in `modes` use their suggested mode.
pub fn convert(html: &str, subject: &str, modes: &BTreeMap<String, Mode>) -> anyhow::Result<Vec<Variant>> {
    let analysis = analyze(html, subject);
    let mode_of = |c: &Conditional| modes.get(&c.var).copied().unwrap_or(c.suggested);
    let split: Vec<String> = analysis
        .conditionals
        .iter()
        .filter(|c| mode_of(c) == Mode::Split)
        .map(|c| c.var.clone())
        .collect();
    if split.len() > MAX_SPLIT_VARS {
        anyhow::bail!(
            "splitting on {} variables would make {} templates; keep or drop some of: {}",
            split.len(),
            1usize << split.len(),
            split.join(", ")
        );
    }
    let mut base: HashMap<String, bool> = HashMap::new();
    for c in &analysis.conditionals {
        match mode_of(c) {
            Mode::Keep => {
                base.insert(c.var.clone(), true);
            }
            Mode::Drop => {
                base.insert(c.var.clone(), false);
            }
            Mode::Split => {}
        }
    }
    let html_nodes = parse(html).nodes;
    let subject_nodes = parse(subject).nodes;
    let mut variants = Vec::new();
    for mask in 0..(1usize << split.len()) {
        let mut truthy = base.clone();
        let mut alias_suffix = String::new();
        let mut labels = Vec::new();
        for (i, var) in split.iter().enumerate() {
            let on = mask & (1 << i) == 0;
            truthy.insert(var.clone(), on);
            let words = var.replace('_', " ");
            alias_suffix.push_str(&format!("-{}-{}", if on { "with" } else { "no" }, var.replace('_', "-")));
            labels.push(format!("{} {}", if on { "with" } else { "no" }, words));
        }
        let mut out_html = String::new();
        render(&html_nodes, &truthy, &mut out_html);
        let mut out_subject = String::new();
        render(&subject_nodes, &truthy, &mut out_subject);
        let variables = used_variables(&format!("{out_subject}\n{out_html}"));
        variants.push(Variant {
            alias_suffix,
            name_suffix: if labels.is_empty() { String::new() } else { format!(" ({})", labels.join(", ")) },
            html: out_html,
            subject: out_subject,
            variables,
        });
    }
    Ok(variants)
}

fn triple_re() -> Regex {
    Regex::new(r"\{\{\{\s*([A-Za-z0-9_.\-]+)\s*\}\}\}").unwrap()
}

/// Variable keys referenced as `{{{KEY}}}`, in first-use order.
pub fn used_variables(text: &str) -> Vec<String> {
    let mut seen = Vec::new();
    for cap in triple_re().captures_iter(text) {
        let k = cap[1].to_string();
        if !seen.contains(&k) {
            seen.push(k);
        }
    }
    seen
}

#[derive(Debug, Serialize, Clone)]
pub struct LintIssue {
    /// "error" blocks a working send, "warn" is probably wrong, "info" is advice.
    pub level: &'static str,
    pub message: String,
}

pub fn lint(html: &str, subject: &str, variables: &[VariableSpec], alias: Option<&str>) -> Vec<LintIssue> {
    let mut issues = Vec::new();
    let mut add = |level: &'static str, message: String| issues.push(LintIssue { level, message });

    let both = format!("{subject}\n{html}");
    let stripped = triple_re().replace_all(&both, "");
    let mut leftovers: Vec<String> = Vec::new();
    let tag_re = Regex::new(r"\{\{[^}]*\}\}").unwrap();
    for m in tag_re.find_iter(&stripped) {
        let t = m.as_str().to_string();
        if !leftovers.contains(&t) {
            leftovers.push(t);
        }
    }
    let blocks: Vec<&String> = leftovers
        .iter()
        .filter(|t| {
            let i = t.trim_start_matches('{').trim();
            i.starts_with('#') || i.starts_with('/') || i.starts_with('^') || i == "else}}" || i.starts_with("else")
        })
        .collect();
    if !blocks.is_empty() {
        add(
            "error",
            format!(
                "Resend has no conditionals or loops; these Handlebars blocks would be sent as literal text: {}",
                blocks.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(" ")
            ),
        );
    }
    let doubles: Vec<&String> = leftovers.iter().filter(|t| !blocks.contains(t)).collect();
    if !doubles.is_empty() {
        add(
            "error",
            format!(
                "Resend only substitutes triple-brace variables like {{{{{{KEY}}}}}}; these would go out as literal text: {}",
                doubles.iter().map(|s| s.as_str()).collect::<Vec<_>>().join(" ")
            ),
        );
    }

    let used = used_variables(&both);
    let declared: Vec<&str> = variables.iter().map(|v| v.key.as_str()).filter(|k| !k.is_empty()).collect();
    for k in &used {
        if !declared.contains(&k.as_str()) {
            add("error", format!("{{{{{{{k}}}}}}} is used but not declared in Variables; Resend rejects sends that reference undeclared variables"));
        }
    }
    for k in &declared {
        if !used.iter().any(|u| u == k) {
            add("warn", format!("{k} is declared but never used in the subject or HTML"));
        }
    }
    let mut dupes = Vec::new();
    for (i, k) in declared.iter().enumerate() {
        if declared[..i].contains(k) && !dupes.contains(k) {
            dupes.push(*k);
            add("error", format!("{k} is declared more than once"));
        }
    }
    for v in variables {
        if v.key.is_empty() {
            add("warn", "a variable row has no key".into());
            continue;
        }
        if RESERVED_KEYS.contains(&v.key.as_str()) {
            add("error", format!("{} is a reserved name in Resend; pick another key", v.key));
        }
        if !v.key.chars().all(|c| c.is_ascii_alphanumeric() || c == '_') {
            add("warn", format!("{} has characters other than letters, digits, and underscores", v.key));
        }
        if v.var_type == "number" {
            if let Some(fb) = &v.fallback {
                let ok = fb.is_number() || fb.as_str().map(|s| s.trim().parse::<f64>().is_ok()).unwrap_or(false);
                if !ok {
                    add("error", format!("{} is a number but its fallback is not", v.key));
                }
            }
        }
        if v.var_type != "string" && v.var_type != "number" {
            add("error", format!("{} has type {}; Resend only supports string and number", v.key, v.var_type));
        }
    }
    if declared.len() > MAX_VARIABLES {
        add("error", format!("{} variables declared; Resend allows at most {MAX_VARIABLES}", declared.len()));
    }
    if subject.trim().is_empty() {
        add("warn", "no subject; every send would have to supply one".into());
    }
    match alias {
        Some(a) if !a.trim().is_empty() => {
            if !a.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-' || c == '_') {
                add("warn", format!("alias {a} should be lowercase kebab-case"));
            }
        }
        _ => add("warn", "no alias; senders usually reference a template by alias, so this one can only be sent by its id".into()),
    }
    let required: Vec<&str> = variables
        .iter()
        .filter(|v| !v.key.is_empty() && v.fallback.as_ref().map(|f| f.is_null()).unwrap_or(true))
        .map(|v| v.key.as_str())
        .collect();
    if !required.is_empty() {
        add("info", format!("required at send time (no fallback): {}", required.join(", ")));
    }
    issues
}

/// Substitutes `{{{KEY}}}` with values, falling back to each variable's
/// fallback. Unresolved keys are left in place. Mirrors Resend's own
/// substitution so a test send matches what a real one would look like.
pub fn substitute(text: &str, values: &serde_json::Map<String, Value>, variables: &[VariableSpec]) -> String {
    triple_re()
        .replace_all(text, |cap: &regex::Captures| {
            let k = &cap[1];
            let v = values
                .get(k)
                .filter(|v| !is_blank(v))
                .or_else(|| variables.iter().find(|s| s.key == k).and_then(|s| s.fallback.as_ref()).filter(|v| !is_blank(v)));
            match v {
                Some(Value::String(s)) => s.clone(),
                Some(other) => other.to_string(),
                None => cap[0].to_string(),
            }
        })
        .into_owned()
}

fn is_blank(v: &Value) -> bool {
    v.is_null() || v.as_str().map(|s| s.is_empty()).unwrap_or(false)
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn converts_vars_to_triple_braces() {
        let v = convert("<p>Hi {{name}}, {{{link}}}</p>", "Hello {{name}}", &BTreeMap::new()).unwrap();
        assert_eq!(v.len(), 1);
        assert_eq!(v[0].html, "<p>Hi {{{name}}}, {{{link}}}</p>");
        assert_eq!(v[0].subject, "Hello {{{name}}}");
        assert_eq!(v[0].variables, vec!["name", "link"]);
    }

    #[test]
    fn splits_on_if() {
        let src = "<p>a</p>{{#if notes}}<p>{{notes}}</p>{{/if}}<p>b</p>";
        let v = convert(src, "s", &BTreeMap::new()).unwrap();
        assert_eq!(v.len(), 2);
        assert_eq!(v[0].alias_suffix, "-with-notes");
        assert_eq!(v[0].html, "<p>a</p><p>{{{notes}}}</p><p>b</p>");
        assert_eq!(v[1].alias_suffix, "-no-notes");
        assert_eq!(v[1].html, "<p>a</p><p>b</p>");
        assert!(v[1].variables.is_empty());
    }

    #[test]
    fn else_text_becomes_fallback_hint() {
        let a = analyze("Hi {{#if name}}{{name}}{{else}}there{{/if}},", "");
        assert_eq!(a.conditionals[0].suggested, Mode::Keep);
        assert_eq!(a.conditionals[0].fallback_hint.as_deref(), Some("there"));
        let v = convert("Hi {{#if name}}{{name}}{{else}}there{{/if}},", "", &BTreeMap::new()).unwrap();
        assert_eq!(v[0].html, "Hi {{{name}}},");
    }

    #[test]
    fn drop_keeps_else_branch() {
        let mut m = BTreeMap::new();
        m.insert("x".to_string(), Mode::Drop);
        let v = convert("{{#if x}}yes{{else}}no{{/if}}", "", &m).unwrap();
        assert_eq!(v[0].html, "no");
    }

    #[test]
    fn nested_and_unsupported() {
        let src = "{{#if a}}A{{#if b}}B{{/if}}{{/if}}{{#each items}}{{this}}{{/each}}";
        let a = analyze(src, "");
        assert_eq!(a.conditionals.len(), 2);
        assert!(a.unsupported.iter().any(|u| u.contains("#each")));
        let mut m = BTreeMap::new();
        m.insert("a".to_string(), Mode::Keep);
        m.insert("b".to_string(), Mode::Drop);
        let v = convert(src, "", &m).unwrap();
        assert_eq!(v[0].html, "A{{#each items}}{{{this}}}{{/each}}");
    }

    #[test]
    fn lint_flags_leftovers_and_undeclared() {
        let vars = vec![VariableSpec { key: "name".into(), var_type: "string".into(), fallback: None, sample: Value::Null, description: None }];
        let issues = lint("{{#if x}}{{y}}{{/if}} {{{name}}} {{{link}}}", "s", &vars, Some("a"));
        let msgs: Vec<String> = issues.iter().map(|i| i.message.clone()).collect();
        assert!(msgs.iter().any(|m| m.contains("no conditionals")));
        assert!(msgs.iter().any(|m| m.contains("{{y}}")));
        assert!(msgs.iter().any(|m| m.contains("{{{link}}} is used but not declared")));
    }

    #[test]
    fn substitute_uses_fallback() {
        let vars = vec![VariableSpec { key: "role".into(), var_type: "string".into(), fallback: Some(Value::String("member".into())), sample: Value::Null, description: None }];
        let mut vals = serde_json::Map::new();
        vals.insert("name".into(), Value::String("Ada".into()));
        assert_eq!(substitute("{{{name}}} as {{{role}}} {{{x}}}", &vals, &vars), "Ada as member {{{x}}}");
    }
}