use crate::{Error, Result};
use regex::Regex;
use serde::Deserialize;
use std::collections::HashMap;
use std::sync::LazyLock;
#[derive(Debug, Deserialize)]
pub struct Frontmatter {
pub subject: String,
#[serde(default = "default_layout")]
pub layout: String,
}
fn default_layout() -> String {
"base".into()
}
static VAR_RE: LazyLock<Regex> =
LazyLock::new(|| Regex::new(r"\{\{([a-zA-Z_][a-zA-Z0-9_]*)\}\}").expect("static regex"));
pub fn substitute(input: &str, vars: &HashMap<String, String>) -> String {
VAR_RE
.replace_all(input, |caps: ®ex::Captures| {
vars.get(&caps[1]).cloned().unwrap_or_default()
})
.into_owned()
}
pub(crate) fn escape_html(input: &str) -> String {
input
.replace('&', "&")
.replace('<', "<")
.replace('>', ">")
.replace('"', """)
}
pub fn parse_frontmatter(template: &str) -> Result<(Frontmatter, String)> {
let normalized = template.replace("\r\n", "\n");
let trimmed = normalized.trim_start();
if !trimmed.starts_with("---") {
return Err(Error::bad_request(
"email template missing frontmatter delimiter '---'",
));
}
let after_first = &trimmed[3..];
let after_first = after_first.strip_prefix('\n').unwrap_or(after_first);
let end = after_first.find("\n---").ok_or_else(|| {
Error::bad_request("email template missing closing frontmatter delimiter '---'")
})?;
let yaml = &after_first[..end];
let body = &after_first[end + 4..]; let body = body.strip_prefix('\n').unwrap_or(body);
let frontmatter: Frontmatter = serde_yaml_ng::from_str(yaml)
.map_err(|e| Error::internal(format!("failed to parse email frontmatter: {e}")))?;
if frontmatter.subject.is_empty() {
return Err(Error::bad_request(
"email template missing required field 'subject'",
));
}
Ok((frontmatter, body.to_string()))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn substitute_replaces_known_vars() {
let mut vars = HashMap::new();
vars.insert("name".into(), "Dmytro".into());
vars.insert("product".into(), "Modo".into());
let result = substitute("Hello {{name}}, welcome to {{product}}!", &vars);
assert_eq!(result, "Hello Dmytro, welcome to Modo!");
}
#[test]
fn substitute_missing_var_becomes_empty() {
let vars = HashMap::new();
let result = substitute("Hello {{name}}!", &vars);
assert_eq!(result, "Hello !");
}
#[test]
fn substitute_preserves_invalid_var_names() {
let vars = HashMap::new();
let result = substitute("Hello {{123invalid}}!", &vars);
assert_eq!(result, "Hello {{123invalid}}!");
}
#[test]
fn substitute_no_vars_in_template() {
let vars = HashMap::new();
let result = substitute("Hello world!", &vars);
assert_eq!(result, "Hello world!");
}
#[test]
fn substitute_special_chars_in_value() {
let mut vars = HashMap::new();
vars.insert("name".into(), "<b>Bold</b>".into());
let result = substitute("Hello {{name}}!", &vars);
assert_eq!(result, "Hello <b>Bold</b>!");
}
#[test]
fn substitute_vars_in_frontmatter() {
let mut vars = HashMap::new();
vars.insert("product".into(), "Modo".into());
vars.insert("name".into(), "Dmytro".into());
let template = "---\nsubject: \"Welcome to {{product}}, {{name}}!\"\n---\nBody";
let result = substitute(template, &vars);
assert!(result.contains("Welcome to Modo, Dmytro!"));
}
#[test]
fn parse_frontmatter_valid() {
let template = "---\nsubject: Welcome!\nlayout: custom\n---\nHello body";
let (fm, body) = parse_frontmatter(template).unwrap();
assert_eq!(fm.subject, "Welcome!");
assert_eq!(fm.layout, "custom");
assert_eq!(body, "Hello body");
}
#[test]
fn parse_frontmatter_default_layout() {
let template = "---\nsubject: Hello\n---\nBody";
let (fm, _) = parse_frontmatter(template).unwrap();
assert_eq!(fm.layout, "base");
}
#[test]
fn parse_frontmatter_empty_body() {
let template = "---\nsubject: Hello\n---\n";
let (fm, body) = parse_frontmatter(template).unwrap();
assert_eq!(fm.subject, "Hello");
assert!(body.is_empty());
}
#[test]
fn parse_frontmatter_missing_delimiter() {
let result = parse_frontmatter("No frontmatter here");
assert!(result.is_err());
}
#[test]
fn parse_frontmatter_missing_closing_delimiter() {
let result = parse_frontmatter("---\nsubject: Hello\nNo closing");
assert!(result.is_err());
}
#[test]
fn parse_frontmatter_missing_subject() {
let result = parse_frontmatter("---\nlayout: base\n---\nBody");
assert!(result.is_err());
}
#[test]
fn parse_frontmatter_empty_subject() {
let result = parse_frontmatter("---\nsubject: \"\"\n---\nBody");
assert!(result.is_err());
}
#[test]
fn escape_html_basic() {
assert_eq!(
escape_html(r#"<b>"Bold" & <i>italic</i></b>"#),
"<b>"Bold" & <i>italic</i></b>"
);
}
#[test]
fn escape_html_empty() {
assert_eq!(escape_html(""), "");
}
#[test]
fn escape_html_no_special_chars() {
assert_eq!(escape_html("Hello world"), "Hello world");
}
#[test]
fn parse_frontmatter_crlf() {
let template = "---\r\nsubject: Welcome!\r\n---\r\nHello body";
let (fm, body) = parse_frontmatter(template).unwrap();
assert_eq!(fm.subject, "Welcome!");
assert_eq!(body, "Hello body");
}
#[test]
fn parse_frontmatter_leading_whitespace() {
let template = " \n---\nsubject: Hello\n---\nBody";
let (fm, body) = parse_frontmatter(template).unwrap();
assert_eq!(fm.subject, "Hello");
assert_eq!(body, "Body");
}
#[test]
fn parse_frontmatter_malformed_yaml() {
let result = parse_frontmatter("---\nsubject: [broken\n---\nBody");
assert!(result.is_err());
}
#[test]
fn parse_frontmatter_body_with_triple_dash() {
let template = "---\nsubject: Hello\n---\nBefore\n---\nAfter";
let (fm, body) = parse_frontmatter(template).unwrap();
assert_eq!(fm.subject, "Hello");
assert_eq!(body, "Before\n---\nAfter");
}
}