1use crate::message::{MailMessage, SendEmail, SenderProfile};
2use crate::template::layout::LayoutEngine;
3use crate::template::{TemplateProvider, markdown, vars};
4use crate::transport::MailTransportDyn;
5use std::sync::Arc;
6
7#[derive(Clone)]
13pub struct Mailer {
14 transport: Arc<dyn MailTransportDyn>,
15 templates: Arc<dyn TemplateProvider>,
16 default_sender: SenderProfile,
17 layout_engine: Arc<LayoutEngine>,
18}
19
20impl Mailer {
21 pub fn new(
26 transport: Arc<dyn MailTransportDyn>,
27 templates: Arc<dyn TemplateProvider>,
28 default_sender: SenderProfile,
29 layout_engine: Arc<LayoutEngine>,
30 ) -> Self {
31 Self {
32 transport,
33 templates,
34 default_sender,
35 layout_engine,
36 }
37 }
38
39 pub fn render(&self, email: &SendEmail) -> Result<MailMessage, modo::Error> {
41 let locale = email.locale.as_deref().unwrap_or("");
42 let template_name = &email.template;
43
44 tracing::debug!(
45 template_name = %template_name,
46 locale = %locale,
47 "resolving email template"
48 );
49
50 let template = self.templates.get(template_name, locale)?;
51
52 let subject = vars::substitute(&template.subject, &email.context);
56 let body = vars::substitute_html(&template.body, &email.context);
57
58 let button_color = email
60 .context
61 .get("brand_color")
62 .and_then(|v| v.as_str())
63 .filter(|s| is_valid_hex_color(s))
64 .unwrap_or(markdown::DEFAULT_BUTTON_COLOR);
65
66 let (html_body, text) = markdown::render(&body, button_color);
68
69 let layout_name = template.layout.as_deref().unwrap_or("default");
71
72 tracing::debug!(
73 layout_name = %layout_name,
74 template_name = %template_name,
75 "rendering email layout"
76 );
77
78 let mut layout_map: std::collections::BTreeMap<String, minijinja::Value> = email
79 .context
80 .iter()
81 .map(|(k, v)| (k.clone(), minijinja::Value::from_serialize(v)))
82 .collect();
83 layout_map.insert("content".to_string(), minijinja::Value::from(html_body));
84 layout_map.insert(
85 "subject".to_string(),
86 minijinja::Value::from(subject.as_str()),
87 );
88 let layout_ctx = minijinja::Value::from_serialize(&layout_map);
89 let html = self.layout_engine.render(layout_name, &layout_ctx)?;
90
91 let sender = email.sender.as_ref().unwrap_or(&self.default_sender);
93
94 Ok(MailMessage {
95 from: sender.format_address(),
96 reply_to: sender.reply_to.clone(),
97 to: email.to.clone(),
98 subject,
99 html,
100 text,
101 })
102 }
103
104 pub async fn send(&self, email: &SendEmail) -> Result<(), modo::Error> {
106 let to = email.to.join(", ");
107 let template_name = &email.template;
108
109 tracing::info!(
110 to = %to,
111 template_name = %template_name,
112 "sending email"
113 );
114
115 let message = self.render(email)?;
116
117 if let Err(e) = self.transport.send(&message).await {
118 tracing::error!(
119 to = %to,
120 template_name = %template_name,
121 error = %e,
122 "email send failed"
123 );
124 return Err(e);
125 }
126
127 tracing::info!(
128 to = %to,
129 template_name = %template_name,
130 "email sent successfully"
131 );
132
133 Ok(())
134 }
135}
136
137fn is_valid_hex_color(s: &str) -> bool {
139 let s = s.as_bytes();
140 matches!(s.len(), 4 | 7) && s[0] == b'#' && s[1..].iter().all(|b| b.is_ascii_hexdigit())
141}
142
143#[cfg(test)]
144mod tests {
145 use super::*;
146 use crate::template::EmailTemplate;
147 use crate::transport::MailTransportSend;
148
149 struct MockTransport {
150 sent: std::sync::Mutex<Vec<MailMessage>>,
151 }
152
153 impl MailTransportSend for MockTransport {
154 async fn send(&self, message: &MailMessage) -> Result<(), modo::Error> {
155 self.sent.lock().unwrap().push(message.clone());
156 Ok(())
157 }
158 }
159
160 struct MockTemplateProvider;
161
162 impl TemplateProvider for MockTemplateProvider {
163 fn get(&self, _name: &str, _locale: &str) -> Result<EmailTemplate, modo::Error> {
164 Ok(EmailTemplate {
165 subject: "Hello {{name}}".to_string(),
166 body: "Hi **{{name}}**!\n\n[button|Click](https://example.com)".to_string(),
167 layout: None,
168 })
169 }
170 }
171
172 fn test_mailer(transport: Arc<dyn MailTransportDyn>) -> Mailer {
173 Mailer::new(
174 transport,
175 Arc::new(MockTemplateProvider),
176 SenderProfile {
177 from_name: "Test".to_string(),
178 from_email: "test@test.com".to_string(),
179 reply_to: None,
180 },
181 Arc::new(LayoutEngine::builtin_only()),
182 )
183 }
184
185 #[tokio::test]
186 async fn send_renders_and_delivers() {
187 let transport = Arc::new(MockTransport {
188 sent: std::sync::Mutex::new(Vec::new()),
189 });
190 let mailer = test_mailer(transport.clone());
191
192 mailer
193 .send(&SendEmail::new("welcome", "user@test.com").var("name", "Alice"))
194 .await
195 .unwrap();
196
197 let messages = transport.sent.lock().unwrap();
198 assert_eq!(messages.len(), 1);
199 assert_eq!(messages[0].to, vec!["user@test.com"]);
200 assert_eq!(messages[0].subject, "Hello Alice");
201 assert!(messages[0].html.contains("Alice"));
202 assert!(messages[0].html.contains("role=\"presentation\"")); assert!(messages[0].text.contains("Alice"));
204 }
205
206 #[tokio::test]
207 async fn sender_override() {
208 let transport = Arc::new(MockTransport {
209 sent: std::sync::Mutex::new(Vec::new()),
210 });
211 let mailer = Mailer::new(
212 transport.clone(),
213 Arc::new(MockTemplateProvider),
214 SenderProfile {
215 from_name: "Default".to_string(),
216 from_email: "default@test.com".to_string(),
217 reply_to: None,
218 },
219 Arc::new(LayoutEngine::builtin_only()),
220 );
221
222 let custom_sender = SenderProfile {
223 from_name: "Tenant".to_string(),
224 from_email: "tenant@custom.com".to_string(),
225 reply_to: Some("support@custom.com".to_string()),
226 };
227
228 mailer
229 .send(
230 &SendEmail::new("welcome", "user@test.com")
231 .sender(&custom_sender)
232 .var("name", "Bob"),
233 )
234 .await
235 .unwrap();
236
237 let messages = transport.sent.lock().unwrap();
238 assert!(messages[0].from.contains("tenant@custom.com"));
239 assert_eq!(messages[0].reply_to.as_deref(), Some("support@custom.com"));
240 }
241
242 #[test]
243 fn render_returns_message_without_sending() {
244 let transport = Arc::new(MockTransport {
245 sent: std::sync::Mutex::new(Vec::new()),
246 });
247 let mailer = test_mailer(transport);
248
249 let msg = mailer
250 .render(&SendEmail::new("welcome", "user@test.com").var("name", "Charlie"))
251 .unwrap();
252
253 assert_eq!(msg.subject, "Hello Charlie");
254 assert!(msg.html.contains("Charlie"));
255 assert!(msg.text.contains("Charlie"));
256 }
257
258 #[test]
259 fn mailer_is_clone() {
260 let transport = Arc::new(MockTransport {
261 sent: std::sync::Mutex::new(Vec::new()),
262 });
263 let mailer = test_mailer(transport);
264 let _clone = mailer.clone();
265 }
266
267 #[test]
268 fn invalid_brand_color_falls_back_to_default() {
269 let transport = Arc::new(MockTransport {
270 sent: std::sync::Mutex::new(Vec::new()),
271 });
272 let mailer = test_mailer(transport);
273
274 let msg = mailer
275 .render(
276 &SendEmail::new("welcome", "user@test.com")
277 .var("name", "Alice")
278 .var("brand_color", "red;position:absolute"),
279 )
280 .unwrap();
281
282 assert!(msg.html.contains(markdown::DEFAULT_BUTTON_COLOR));
284 assert!(!msg.html.contains("position:absolute"));
285 }
286
287 #[test]
288 fn layout_content_with_extra_context_vars() {
289 let transport = Arc::new(MockTransport {
290 sent: std::sync::Mutex::new(Vec::new()),
291 });
292 let mailer = test_mailer(transport);
293
294 let msg = mailer
295 .render(
296 &SendEmail::new("welcome", "user@test.com")
297 .var("name", "Alice")
298 .var("footer_text", "My Footer"),
299 )
300 .unwrap();
301
302 assert!(
304 msg.html.contains("Alice"),
305 "html should contain rendered content"
306 );
307 assert!(
309 msg.html.contains("My Footer"),
310 "html should contain footer_text"
311 );
312 }
313
314 #[test]
315 fn valid_brand_color_is_used() {
316 let transport = Arc::new(MockTransport {
317 sent: std::sync::Mutex::new(Vec::new()),
318 });
319 let mailer = test_mailer(transport);
320
321 let msg = mailer
322 .render(
323 &SendEmail::new("welcome", "user@test.com")
324 .var("name", "Alice")
325 .var("brand_color", "#ff6600"),
326 )
327 .unwrap();
328
329 assert!(msg.html.contains("#ff6600"));
330 }
331}