Skip to main content

modo_email/template/
email_template.rs

1use serde::Deserialize;
2
3/// A parsed email template with subject, body, and optional layout.
4#[derive(Clone)]
5pub struct EmailTemplate {
6    /// Subject line with `{{var}}` placeholders (not yet substituted).
7    pub subject: String,
8    /// Markdown body with `{{var}}` placeholders (not yet substituted).
9    pub body: String,
10    /// Name of the layout to wrap the body in, or `None` to use `"default"`.
11    pub layout: Option<String>,
12}
13
14#[derive(Deserialize)]
15struct Frontmatter {
16    subject: String,
17    layout: Option<String>,
18}
19
20impl EmailTemplate {
21    /// Parse a raw template string with YAML frontmatter delimited by `---`.
22    ///
23    /// The frontmatter must contain at least a `subject` field.
24    /// An optional `layout` field specifies which layout to wrap the body in.
25    pub fn parse(raw: &str) -> Result<Self, modo::Error> {
26        let raw = raw.trim();
27        if !raw.starts_with("---") {
28            return Err(modo::Error::internal(
29                "email template must start with YAML frontmatter (---)",
30            ));
31        }
32
33        let after_first = &raw[3..];
34        let end = after_first.find("---").ok_or_else(|| {
35            modo::Error::internal("email template frontmatter missing closing ---")
36        })?;
37
38        let yaml = &after_first[..end];
39        let body = &after_first[end + 3..];
40
41        let fm: Frontmatter = serde_yaml_ng::from_str(yaml)
42            .map_err(|e| modo::Error::internal(format!("invalid frontmatter: {e}")))?;
43
44        Ok(Self {
45            subject: fm.subject,
46            body: body.to_string(),
47            layout: fm.layout,
48        })
49    }
50}
51
52/// Trait for loading email templates by name and locale.
53///
54/// Implement this to load templates from a database, cache, or any source
55/// other than the filesystem. Pass the implementation to [`mailer_with`](crate::mailer_with).
56pub trait TemplateProvider: Send + Sync + 'static {
57    /// Return the template identified by `name`, resolving to the given `locale`
58    /// when available. Pass an empty string for `locale` to request the default.
59    fn get(&self, name: &str, locale: &str) -> Result<EmailTemplate, modo::Error>;
60}
61
62#[cfg(test)]
63mod tests {
64    use super::*;
65
66    #[test]
67    fn parse_template_with_frontmatter() {
68        let raw = "---\nsubject: \"Hello {{name}}\"\nlayout: custom\n---\n\nBody here.";
69        let tpl = EmailTemplate::parse(raw).unwrap();
70        assert_eq!(tpl.subject, "Hello {{name}}");
71        assert_eq!(tpl.layout.as_deref(), Some("custom"));
72        assert_eq!(tpl.body.trim(), "Body here.");
73    }
74
75    #[test]
76    fn parse_template_default_layout() {
77        let raw = "---\nsubject: \"Hi\"\n---\n\nContent.";
78        let tpl = EmailTemplate::parse(raw).unwrap();
79        assert_eq!(tpl.subject, "Hi");
80        assert!(tpl.layout.is_none());
81    }
82
83    #[test]
84    fn parse_template_missing_subject() {
85        let raw = "---\nlayout: default\n---\n\nNo subject.";
86        let result = EmailTemplate::parse(raw);
87        assert!(result.is_err());
88    }
89
90    #[test]
91    fn parse_template_no_frontmatter() {
92        let raw = "Just markdown, no frontmatter.";
93        let result = EmailTemplate::parse(raw);
94        assert!(result.is_err());
95    }
96
97    #[test]
98    fn parse_empty_input() {
99        let result = EmailTemplate::parse("");
100        assert!(result.is_err());
101    }
102
103    #[test]
104    fn parse_only_opening_delimiter() {
105        let result = EmailTemplate::parse("---");
106        assert!(result.is_err());
107    }
108
109    #[test]
110    fn parse_body_contains_triple_dash() {
111        let raw = "---\nsubject: \"Hi\"\n---\nBody\n---\nMore body";
112        let tpl = EmailTemplate::parse(raw).unwrap();
113        assert_eq!(tpl.subject, "Hi");
114        assert!(tpl.body.contains("Body"));
115        assert!(tpl.body.contains("---"));
116        assert!(tpl.body.contains("More body"));
117    }
118
119    #[test]
120    fn parse_empty_body() {
121        let raw = "---\nsubject: \"Hi\"\n---";
122        let tpl = EmailTemplate::parse(raw).unwrap();
123        assert_eq!(tpl.subject, "Hi");
124        assert!(tpl.body.trim().is_empty());
125    }
126
127    #[test]
128    fn parse_unicode_subject_and_body() {
129        let raw = "---\nsubject: \"Willkommen 🎉\"\n---\n你好世界";
130        let tpl = EmailTemplate::parse(raw).unwrap();
131        assert_eq!(tpl.subject, "Willkommen 🎉");
132        assert!(tpl.body.contains("你好世界"));
133    }
134
135    #[test]
136    fn parse_extra_whitespace_around_frontmatter() {
137        let raw = "  \n---\nsubject: \"Hi\"\n---\nBody  \n  ";
138        let tpl = EmailTemplate::parse(raw).unwrap();
139        assert_eq!(tpl.subject, "Hi");
140        assert!(tpl.body.contains("Body"));
141    }
142
143    #[test]
144    fn parse_extra_fields_ignored() {
145        let raw = "---\nsubject: \"Hi\"\npriority: high\ncustom_key: value\n---\nBody";
146        let tpl = EmailTemplate::parse(raw).unwrap();
147        assert_eq!(tpl.subject, "Hi");
148        assert!(tpl.body.contains("Body"));
149    }
150}