Skip to main content

modo/email/
mailer.rs

1use crate::email::cache::CachedSource;
2use crate::email::layout;
3use crate::email::markdown;
4use crate::email::message::{RenderedEmail, SendEmail};
5use crate::email::render;
6use crate::email::source::{FileSource, TemplateSource};
7use crate::{Error, Result};
8use lettre::message::{MultiPart, SinglePart, header::ContentType};
9use lettre::{AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor};
10use std::collections::HashMap;
11use std::sync::Arc;
12
13use crate::email::config::{EmailConfig, SmtpSecurity};
14
15enum Transport {
16    Smtp(AsyncSmtpTransport<Tokio1Executor>),
17    #[cfg(any(test, feature = "test-helpers"))]
18    Stub(lettre::transport::stub::AsyncStubTransport),
19}
20
21struct Inner {
22    source: Arc<dyn TemplateSource>,
23    transport: Transport,
24    config: EmailConfig,
25    layouts: HashMap<String, String>,
26}
27
28/// The primary entry point for sending transactional email.
29///
30/// `Mailer` loads Markdown templates, performs variable substitution, renders
31/// HTML and plain-text bodies, applies a layout, and delivers the resulting
32/// message over SMTP.
33///
34/// Cloning is cheap (`Arc`-based) and shares the SMTP connection, template
35/// source, and preloaded layouts.
36///
37/// # Construction
38///
39/// - [`Mailer::new`] — uses a [`FileSource`] (optionally cached) derived from
40///   `EmailConfig::templates_path`.
41/// - [`Mailer::with_source`] — accepts any custom [`TemplateSource`].
42/// - `Mailer::with_stub_transport` — in-memory stub for tests
43///   (requires feature `"test-helpers"` or `#[cfg(test)]`).
44#[derive(Clone)]
45pub struct Mailer {
46    inner: Arc<Inner>,
47}
48
49impl Mailer {
50    /// Create a new `Mailer` with the default [`FileSource`].
51    ///
52    /// If `config.cache_templates` is `true`, the file source is wrapped in a
53    /// [`CachedSource`] with `config.template_cache_size` capacity.
54    ///
55    /// # Errors
56    ///
57    /// Returns an error if the SMTP transport cannot be built (e.g., invalid
58    /// host, mismatched credentials) or if the layouts directory cannot be
59    /// read.
60    pub fn new(config: &EmailConfig) -> Result<Self> {
61        let file_source = FileSource::new(&config.templates_path);
62        let source: Arc<dyn TemplateSource> = if config.cache_templates {
63            Arc::new(CachedSource::new(file_source, config.template_cache_size))
64        } else {
65            Arc::new(file_source)
66        };
67
68        let transport = Self::build_smtp_transport(config)?;
69        let layouts = layout::load_layouts(&config.layouts_path)?;
70
71        Ok(Self {
72            inner: Arc::new(Inner {
73                source,
74                transport: Transport::Smtp(transport),
75                config: config.clone(),
76                layouts,
77            }),
78        })
79    }
80
81    /// Create a new `Mailer` with a custom [`TemplateSource`].
82    ///
83    /// Use this to supply an in-memory source, a database-backed source, or
84    /// any other custom implementation.
85    ///
86    /// # Errors
87    ///
88    /// Returns an error if the SMTP transport cannot be built or if the
89    /// layouts directory cannot be read.
90    pub fn with_source(config: &EmailConfig, source: Arc<dyn TemplateSource>) -> Result<Self> {
91        let transport = Self::build_smtp_transport(config)?;
92        let layouts = layout::load_layouts(&config.layouts_path)?;
93
94        Ok(Self {
95            inner: Arc::new(Inner {
96                source,
97                transport: Transport::Smtp(transport),
98                config: config.clone(),
99                layouts,
100            }),
101        })
102    }
103
104    /// Create a `Mailer` with a stub transport for testing.
105    ///
106    /// Requires feature `"test-helpers"` or `#[cfg(test)]`. The stub transport accepts messages
107    /// without actually sending them over a network.
108    ///
109    /// # Errors
110    ///
111    /// Returns an error if the layouts directory cannot be read.
112    #[cfg(any(test, feature = "test-helpers"))]
113    pub fn with_stub_transport(
114        config: &EmailConfig,
115        stub: lettre::transport::stub::AsyncStubTransport,
116    ) -> Result<Self> {
117        let file_source = FileSource::new(&config.templates_path);
118        let source: Arc<dyn TemplateSource> = if config.cache_templates {
119            Arc::new(CachedSource::new(file_source, config.template_cache_size))
120        } else {
121            Arc::new(file_source)
122        };
123        let layouts = layout::load_layouts(&config.layouts_path)?;
124
125        Ok(Self {
126            inner: Arc::new(Inner {
127                source,
128                transport: Transport::Stub(stub),
129                config: config.clone(),
130                layouts,
131            }),
132        })
133    }
134
135    fn build_smtp_transport(config: &EmailConfig) -> Result<AsyncSmtpTransport<Tokio1Executor>> {
136        // Validate SMTP auth: both set or both empty
137        match (&config.smtp.username, &config.smtp.password) {
138            (Some(_), None) | (None, Some(_)) => {
139                return Err(Error::bad_request(
140                    "SMTP username and password must both be set or both be empty",
141                ));
142            }
143            _ => {}
144        }
145
146        let builder = match config.smtp.security {
147            SmtpSecurity::Tls => AsyncSmtpTransport::<Tokio1Executor>::relay(&config.smtp.host)
148                .map_err(|e| Error::internal(format!("SMTP relay error: {e}")))?,
149            SmtpSecurity::StartTls => {
150                AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&config.smtp.host)
151                    .map_err(|e| Error::internal(format!("SMTP STARTTLS error: {e}")))?
152            }
153            SmtpSecurity::None => {
154                AsyncSmtpTransport::<Tokio1Executor>::builder_dangerous(&config.smtp.host)
155            }
156        };
157
158        let builder = builder.port(config.smtp.port);
159
160        let builder = if let (Some(username), Some(password)) =
161            (&config.smtp.username, &config.smtp.password)
162        {
163            builder.credentials(lettre::transport::smtp::authentication::Credentials::new(
164                username.clone(),
165                password.clone(),
166            ))
167        } else {
168            builder
169        };
170
171        Ok(builder.build())
172    }
173
174    /// Render a template without sending.
175    ///
176    /// Performs variable substitution, parses the YAML frontmatter, converts
177    /// the Markdown body to HTML (with button syntax support), applies the
178    /// layout, and generates the plain-text fallback.
179    ///
180    /// Returns a [`RenderedEmail`] containing the subject, HTML, and text.
181    ///
182    /// # Errors
183    ///
184    /// Returns an error if the template cannot be loaded, the frontmatter is
185    /// missing or malformed, or the requested layout is not found.
186    pub fn render(&self, email: &SendEmail) -> Result<RenderedEmail> {
187        let locale = email
188            .locale
189            .as_deref()
190            .unwrap_or(&self.inner.config.default_locale);
191
192        // Load raw template
193        let raw =
194            self.inner
195                .source
196                .load(&email.template, locale, &self.inner.config.default_locale)?;
197
198        // Stage 1: Substitute variables
199        let substituted = render::substitute(&raw, &email.vars);
200
201        // Stage 2: Parse frontmatter
202        let (frontmatter, body) = render::parse_frontmatter(&substituted)?;
203
204        // Stage 3: Render markdown to HTML
205        let brand_color = email.vars.get("brand_color").map(|s| s.as_str());
206        let html_body = markdown::markdown_to_html(&body, brand_color);
207
208        // Stage 4: Apply layout
209        let layout_html = layout::resolve_layout(&frontmatter.layout, &self.inner.layouts)?;
210        let html = layout::apply_layout(&layout_html, &html_body, &email.vars);
211
212        // Stage 5: Plain text
213        let text = markdown::markdown_to_text(&body);
214
215        Ok(RenderedEmail {
216            subject: frontmatter.subject,
217            html,
218            text,
219        })
220    }
221
222    /// Render and send an email via SMTP.
223    ///
224    /// Calls [`Self::render`] internally, then builds a `multipart/alternative`
225    /// MIME message (text/plain + text/html) and delivers it over the
226    /// configured transport.
227    ///
228    /// # Errors
229    ///
230    /// Returns an error if the recipient list is empty, if any address is
231    /// malformed, if the template cannot be rendered, or if the SMTP delivery
232    /// fails.
233    pub async fn send(&self, email: SendEmail) -> Result<()> {
234        if email.to.is_empty() {
235            return Err(Error::bad_request("email has no recipients"));
236        }
237
238        let rendered = self.render(&email)?;
239
240        // Build sender
241        let from_name = email
242            .sender
243            .as_ref()
244            .map(|s| &s.from_name)
245            .unwrap_or(&self.inner.config.default_from_name);
246        let from_email = email
247            .sender
248            .as_ref()
249            .map(|s| &s.from_email)
250            .unwrap_or(&self.inner.config.default_from_email);
251        let reply_to = email
252            .sender
253            .as_ref()
254            .and_then(|s| s.reply_to.as_deref())
255            .or(self.inner.config.default_reply_to.as_deref());
256
257        let from = if from_name.is_empty() {
258            from_email.parse()
259        } else {
260            format!("{from_name} <{from_email}>").parse()
261        }
262        .map_err(|e| Error::bad_request(format!("invalid from address: {e}")))?;
263
264        let mut builder = Message::builder().from(from).subject(&rendered.subject);
265
266        for to_addr in &email.to {
267            builder = builder.to(to_addr
268                .parse()
269                .map_err(|e| Error::bad_request(format!("invalid to address '{to_addr}': {e}")))?);
270        }
271
272        for cc_addr in &email.cc {
273            builder = builder.cc(cc_addr
274                .parse()
275                .map_err(|e| Error::bad_request(format!("invalid cc address '{cc_addr}': {e}")))?);
276        }
277
278        for bcc_addr in &email.bcc {
279            builder = builder.bcc(bcc_addr.parse().map_err(|e| {
280                Error::bad_request(format!("invalid bcc address '{bcc_addr}': {e}"))
281            })?);
282        }
283
284        if let Some(reply_to_addr) = reply_to {
285            builder = builder.reply_to(
286                reply_to_addr
287                    .parse()
288                    .map_err(|e| Error::bad_request(format!("invalid reply-to address: {e}")))?,
289            );
290        }
291
292        let message = builder
293            .multipart(
294                MultiPart::alternative()
295                    .singlepart(
296                        SinglePart::builder()
297                            .header(ContentType::TEXT_PLAIN)
298                            .body(rendered.text),
299                    )
300                    .singlepart(
301                        SinglePart::builder()
302                            .header(ContentType::TEXT_HTML)
303                            .body(rendered.html),
304                    ),
305            )
306            .map_err(|e| Error::internal(format!("failed to build email message: {e}")))?;
307
308        match &self.inner.transport {
309            Transport::Smtp(transport) => {
310                transport
311                    .send(message)
312                    .await
313                    .map_err(|e| Error::internal(format!("failed to send email: {e}")))?;
314            }
315            #[cfg(any(test, feature = "test-helpers"))]
316            Transport::Stub(transport) => {
317                transport
318                    .send(message)
319                    .await
320                    .map_err(|e| Error::internal(format!("failed to send email (stub): {e}")))?;
321            }
322        }
323
324        Ok(())
325    }
326}
327
328#[cfg(test)]
329mod tests {
330    use super::*;
331    use crate::email::config::SmtpConfig;
332
333    fn test_email_config(smtp: SmtpConfig) -> EmailConfig {
334        EmailConfig {
335            templates_path: "/tmp/nonexistent".into(),
336            layouts_path: "/tmp/nonexistent".into(),
337            default_from_name: "Test".into(),
338            default_from_email: "test@example.com".into(),
339            default_reply_to: None,
340            default_locale: "en".into(),
341            cache_templates: false,
342            template_cache_size: 10,
343            smtp,
344        }
345    }
346
347    #[test]
348    fn build_smtp_transport_username_without_password() {
349        let config = test_email_config(SmtpConfig {
350            host: "localhost".into(),
351            port: 25,
352            username: Some("user".into()),
353            password: None,
354            security: SmtpSecurity::None,
355        });
356        let result = Mailer::build_smtp_transport(&config);
357        assert!(result.is_err());
358    }
359
360    #[test]
361    fn build_smtp_transport_password_without_username() {
362        let config = test_email_config(SmtpConfig {
363            host: "localhost".into(),
364            port: 25,
365            username: None,
366            password: Some("pass".into()),
367            security: SmtpSecurity::None,
368        });
369        let result = Mailer::build_smtp_transport(&config);
370        assert!(result.is_err());
371    }
372
373    #[test]
374    fn with_source_creates_mailer() {
375        struct MockSource;
376        impl TemplateSource for MockSource {
377            fn load(&self, _name: &str, _locale: &str, _default_locale: &str) -> Result<String> {
378                Ok("---\nsubject: Test\n---\nBody".into())
379            }
380        }
381
382        let config = test_email_config(SmtpConfig {
383            host: "localhost".into(),
384            port: 25,
385            username: None,
386            password: None,
387            security: SmtpSecurity::None,
388        });
389        let source: Arc<dyn TemplateSource> = Arc::new(MockSource);
390        let mailer = Mailer::with_source(&config, source).unwrap();
391
392        let email = SendEmail::new("any", "user@example.com");
393        let rendered = mailer.render(&email).unwrap();
394        assert_eq!(rendered.subject, "Test");
395    }
396}