app
Resend Studio
Public Made by Adomby adom
A local editor for Resend email templates: paste your API key, then edit, preview, lint, test-send, and publish templates without Resend's dashboard. Also imports SendGrid dynamic templates and converts their Handlebars to Resend's variable syntax.
main
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534
//! 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: ®ex::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}}}");
}
}