modo_email/template/
email_template.rs1use serde::Deserialize;
2
3#[derive(Clone)]
5pub struct EmailTemplate {
6 pub subject: String,
8 pub body: String,
10 pub layout: Option<String>,
12}
13
14#[derive(Deserialize)]
15struct Frontmatter {
16 subject: String,
17 layout: Option<String>,
18}
19
20impl EmailTemplate {
21 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
52pub trait TemplateProvider: Send + Sync + 'static {
57 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}