Skip to main content

modo_email/
mailer.rs

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/// High-level email service that ties together template loading, variable
8/// substitution, Markdown rendering, layout wrapping, and transport delivery.
9///
10/// `Mailer` is cheaply cloneable via internal `Arc`s, making it safe to share
11/// across async tasks and register as a modo service.
12#[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    /// Construct a `Mailer` from its constituent parts.
22    ///
23    /// Prefer the [`mailer`](crate::mailer) or [`mailer_with`](crate::mailer_with)
24    /// factory functions for typical usage.
25    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    /// Render a `SendEmail` into a fully-formed `MailMessage` without sending.
40    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        // Substitute variables in subject and body.
53        // Subject is a plain-text RFC 5322 header — must NOT be HTML-escaped.
54        // Body flows through Markdown → HTML layout, so values must be HTML-escaped.
55        let subject = vars::substitute(&template.subject, &email.context);
56        let body = vars::substitute_html(&template.body, &email.context);
57
58        // Validate brand_color as a CSS hex color; fall back to default if invalid.
59        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        // Render Markdown body to HTML and plain text in one pass.
67        let (html_body, text) = markdown::render(&body, button_color);
68
69        // Wrap HTML body in a layout.
70        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        // Resolve sender (per-email override or default).
92        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    /// Render and deliver an email via the configured transport.
105    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
137/// Validate that a string is a valid CSS hex color (#RGB or #RRGGBB).
138fn 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\"")); // button
203        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        // Should use default color, not the injection attempt
283        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        // content (rendered markdown) must be present in the layout
303        assert!(
304            msg.html.contains("Alice"),
305            "html should contain rendered content"
306        );
307        // extra context var must also pass through to layout
308        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}