Skip to main content

sova_mail/
email.rs

1//! Fluent email builder → `lettre::Message`.
2
3use crate::client::MailClient;
4use lettre::message::{header::ContentType, Attachment as LAttach, MultiPart, SinglePart};
5use lettre::Message;
6use sova_core::{Error, Result};
7use std::path::PathBuf;
8
9/// Built message metadata (fake transport / asserts).
10#[derive(Clone, Debug)]
11pub struct EmailSnapshot {
12    pub from: String,
13    pub to: Vec<String>,
14    pub cc: Vec<String>,
15    pub bcc: Vec<String>,
16    pub subject: String,
17    pub text: Option<String>,
18    pub html: Option<String>,
19    pub attachments: Vec<String>,
20}
21
22#[cfg(feature = "templates")]
23#[derive(Clone)]
24struct PendingView {
25    name: String,
26    ctx: serde_json::Value,
27}
28
29/// Fluent outbound message (Nodemailer / Laravel-style).
30#[derive(Clone)]
31pub struct Email {
32    client: Option<MailClient>,
33    from: Option<String>,
34    to: Vec<String>,
35    cc: Vec<String>,
36    bcc: Vec<String>,
37    subject: String,
38    text: Option<String>,
39    html: Option<String>,
40    attachments: Vec<Attachment>,
41    #[cfg(feature = "templates")]
42    html_view: Option<PendingView>,
43    #[cfg(feature = "templates")]
44    text_view: Option<PendingView>,
45    #[cfg(feature = "templates")]
46    ambient: Option<sova_templates::FrozenAmbient>,
47    #[cfg(feature = "markdown")]
48    pending_markdown: Option<String>,
49    #[cfg(all(feature = "templates", feature = "markdown"))]
50    markdown_view: Option<PendingView>,
51}
52
53#[derive(Clone)]
54enum Attachment {
55    Path(PathBuf),
56    Bytes { filename: String, data: Vec<u8> },
57}
58
59impl Email {
60    pub fn new() -> Self {
61        Self {
62            client: None,
63            from: None,
64            to: Vec::new(),
65            cc: Vec::new(),
66            bcc: Vec::new(),
67            subject: String::new(),
68            text: None,
69            html: None,
70            attachments: Vec::new(),
71            #[cfg(feature = "templates")]
72            html_view: None,
73            #[cfg(feature = "templates")]
74            text_view: None,
75            #[cfg(feature = "templates")]
76            ambient: None,
77            #[cfg(feature = "markdown")]
78            pending_markdown: None,
79            #[cfg(all(feature = "templates", feature = "markdown"))]
80            markdown_view: None,
81        }
82    }
83
84    pub(crate) fn with_client(client: MailClient) -> Self {
85        let from = client.default_from.clone();
86        #[cfg(feature = "templates")]
87        let ambient = client.templates().map(|t| t.freeze_globals());
88        Self {
89            client: Some(client),
90            from,
91            #[cfg(feature = "templates")]
92            ambient,
93            ..Self::new()
94        }
95    }
96
97    #[cfg(feature = "templates")]
98    pub(crate) fn with_ambient(mut self, ambient: sova_templates::FrozenAmbient) -> Self {
99        self.ambient = Some(ambient);
100        self
101    }
102
103    pub fn from(mut self, addr: impl Into<String>) -> Self {
104        self.from = Some(addr.into());
105        self
106    }
107
108    pub fn to(mut self, addr: impl Into<String>) -> Self {
109        self.to.push(addr.into());
110        self
111    }
112
113    pub fn cc(mut self, addr: impl Into<String>) -> Self {
114        self.cc.push(addr.into());
115        self
116    }
117
118    pub fn bcc(mut self, addr: impl Into<String>) -> Self {
119        self.bcc.push(addr.into());
120        self
121    }
122
123    pub fn subject(mut self, subject: impl Into<String>) -> Self {
124        self.subject = subject.into();
125        self
126    }
127
128    pub fn text(mut self, body: impl Into<String>) -> Self {
129        self.text = Some(body.into());
130        #[cfg(feature = "templates")]
131        {
132            self.text_view = None;
133        }
134        self
135    }
136
137    pub fn html(mut self, body: impl Into<String>) -> Self {
138        self.html = Some(body.into());
139        #[cfg(feature = "templates")]
140        {
141            self.html_view = None;
142        }
143        #[cfg(feature = "markdown")]
144        {
145            self.pending_markdown = None;
146        }
147        #[cfg(all(feature = "templates", feature = "markdown"))]
148        {
149            self.markdown_view = None;
150        }
151        self
152    }
153
154    /// Defer MiniJinja HTML render until [`Self::send`] (Laravel-style `view`).
155    ///
156    /// Layouts: `{% extends "mail/layout.html" %}` in the template file.
157    /// Requires feature `templates` and an installed [`sova_templates::MiniJinjaTemplates`]
158    /// wired onto the [`MailClient`] (Templates → Mail install order, or startup hook).
159    #[cfg(feature = "templates")]
160    pub fn view<T: serde::Serialize>(mut self, name: impl Into<String>, ctx: T) -> Self {
161        let ctx = serde_json::to_value(ctx).unwrap_or(serde_json::Value::Null);
162        self.html_view = Some(PendingView {
163            name: name.into(),
164            ctx,
165        });
166        self.html = None;
167        #[cfg(feature = "markdown")]
168        {
169            self.pending_markdown = None;
170        }
171        #[cfg(all(feature = "templates", feature = "markdown"))]
172        {
173            self.markdown_view = None;
174        }
175        self
176    }
177
178    /// Like [`Self::view`], but for the plain-text body.
179    #[cfg(feature = "templates")]
180    pub fn text_view<T: serde::Serialize>(mut self, name: impl Into<String>, ctx: T) -> Self {
181        let ctx = serde_json::to_value(ctx).unwrap_or(serde_json::Value::Null);
182        self.text_view = Some(PendingView {
183            name: name.into(),
184            ctx,
185        });
186        self.text = None;
187        self
188    }
189
190    /// Defer markdown→HTML until [`Self::send`] (feature `markdown`).
191    ///
192    /// Also sets the plain-text body to the raw markdown source (unless you call
193    /// [`Self::text`] afterwards).
194    #[cfg(feature = "markdown")]
195    pub fn markdown(mut self, md: impl Into<String>) -> Self {
196        let md = md.into();
197        self.text = Some(md.clone());
198        self.pending_markdown = Some(md);
199        self.html = None;
200        #[cfg(feature = "templates")]
201        {
202            self.html_view = None;
203            self.markdown_view = None;
204        }
205        self
206    }
207
208    /// Render a MiniJinja template as markdown, then convert to HTML at send.
209    #[cfg(all(feature = "templates", feature = "markdown"))]
210    pub fn markdown_view<T: serde::Serialize>(mut self, name: impl Into<String>, ctx: T) -> Self {
211        let ctx = serde_json::to_value(ctx).unwrap_or(serde_json::Value::Null);
212        self.markdown_view = Some(PendingView {
213            name: name.into(),
214            ctx,
215        });
216        self.html = None;
217        self.html_view = None;
218        self.pending_markdown = None;
219        self
220    }
221
222    pub fn attach(mut self, path: impl Into<PathBuf>) -> Self {
223        self.attachments.push(Attachment::Path(path.into()));
224        self
225    }
226
227    pub fn attach_bytes(mut self, filename: impl Into<String>, data: impl Into<Vec<u8>>) -> Self {
228        self.attachments.push(Attachment::Bytes {
229            filename: filename.into(),
230            data: data.into(),
231        });
232        self
233    }
234
235    /// Send via the client bound by [`MailExt::mail`] / [`MailClient::compose`].
236    pub async fn send(self) -> Result<()> {
237        let client = self
238            .client
239            .clone()
240            .ok_or_else(|| Error::Internal("mail: no client — use MailClient::send".into()))?;
241        client.send(self).await
242    }
243
244    #[cfg(any(feature = "templates", feature = "markdown"))]
245    pub(crate) fn resolve_body(&mut self, client: &MailClient) -> Result<()> {
246        #[cfg(feature = "templates")]
247        {
248            let needs_templates = self.html_view.is_some()
249                || self.text_view.is_some()
250                || {
251                    #[cfg(feature = "markdown")]
252                    {
253                        self.markdown_view.is_some()
254                    }
255                    #[cfg(not(feature = "markdown"))]
256                    {
257                        false
258                    }
259                };
260            if needs_templates {
261                let templates = client.templates().ok_or_else(|| {
262                    Error::Internal(
263                        "mail view requires Templates plugin (install Templates before Mail, or enable mail-templates)"
264                            .into(),
265                    )
266                })?;
267                let ambient = self
268                    .ambient
269                    .clone()
270                    .unwrap_or_else(|| templates.freeze_globals());
271                if let Some(view) = self.html_view.take() {
272                    self.html = Some(templates.render_owned(&ambient, &view.name, view.ctx)?);
273                }
274                if let Some(view) = self.text_view.take() {
275                    self.text = Some(templates.render_owned(&ambient, &view.name, view.ctx)?);
276                }
277                #[cfg(feature = "markdown")]
278                if let Some(view) = self.markdown_view.take() {
279                    let md = templates.render_owned(&ambient, &view.name, view.ctx)?;
280                    if self.text.is_none() {
281                        self.text = Some(md.clone());
282                    }
283                    self.html = Some(crate::markdown::to_html(&md));
284                }
285                let _ = self.ambient.take();
286            }
287        }
288
289        #[cfg(feature = "markdown")]
290        if let Some(md) = self.pending_markdown.take() {
291            self.html = Some(crate::markdown::to_html(&md));
292        }
293
294        Ok(())
295    }
296
297    pub(crate) fn snapshot(&self) -> EmailSnapshot {
298        EmailSnapshot {
299            from: self.from.clone().unwrap_or_default(),
300            to: self.to.clone(),
301            cc: self.cc.clone(),
302            bcc: self.bcc.clone(),
303            subject: self.subject.clone(),
304            text: self.text.clone(),
305            html: self.html.clone(),
306            attachments: self
307                .attachments
308                .iter()
309                .map(|a| match a {
310                    Attachment::Path(p) => p.display().to_string(),
311                    Attachment::Bytes { filename, .. } => filename.clone(),
312                })
313                .collect(),
314        }
315    }
316
317    pub(crate) fn into_message(self, default_from: Option<&str>) -> Result<(EmailSnapshot, Message)> {
318        if self.to.is_empty() {
319            return Err(Error::BadRequest("mail: at least one `to` required".into()));
320        }
321        let from = self
322            .from
323            .as_deref()
324            .or(default_from)
325            .ok_or_else(|| Error::BadRequest("mail: `from` required".into()))?
326            .to_string();
327
328        let mut snap = self.snapshot();
329        snap.from = from.clone();
330
331        let mut builder = Message::builder().from(
332            from.parse()
333                .map_err(|e| Error::BadRequest(format!("mail from: {e}")))?,
334        );
335        for addr in &self.to {
336            builder = builder.to(addr
337                .parse()
338                .map_err(|e| Error::BadRequest(format!("mail to: {e}")))?);
339        }
340        for addr in &self.cc {
341            builder = builder.cc(addr
342                .parse()
343                .map_err(|e| Error::BadRequest(format!("mail cc: {e}")))?);
344        }
345        for addr in &self.bcc {
346            builder = builder.bcc(addr
347                .parse()
348                .map_err(|e| Error::BadRequest(format!("mail bcc: {e}")))?);
349        }
350        builder = builder.subject(&self.subject);
351
352        let content = match (&self.text, &self.html) {
353            (Some(text), Some(html)) => MultiPart::alternative()
354                .singlepart(plain(text))
355                .singlepart(html_part(html)),
356            (Some(text), None) => MultiPart::mixed().singlepart(plain(text)),
357            (None, Some(html)) => MultiPart::mixed().singlepart(html_part(html)),
358            (None, None) => MultiPart::mixed().singlepart(plain("")),
359        };
360
361        let message = if self.attachments.is_empty() {
362            builder
363                .multipart(content)
364                .map_err(|e| Error::BadRequest(format!("mail build: {e}")))?
365        } else {
366            let mut mixed = MultiPart::mixed().multipart(content);
367            for att in &self.attachments {
368                let (filename, data) = match att {
369                    Attachment::Path(path) => {
370                        let filename = path
371                            .file_name()
372                            .and_then(|s| s.to_str())
373                            .unwrap_or("attachment")
374                            .to_string();
375                        let data = std::fs::read(path).map_err(|e| {
376                            Error::Internal(format!("mail attach {}: {e}", path.display()))
377                        })?;
378                        (filename, data)
379                    }
380                    Attachment::Bytes { filename, data } => (filename.clone(), data.clone()),
381                };
382                let ct = content_type_for(&filename);
383                mixed = mixed.singlepart(LAttach::new(filename).body(data, ct));
384            }
385            builder
386                .multipart(mixed)
387                .map_err(|e| Error::BadRequest(format!("mail build: {e}")))?
388        };
389
390        Ok((snap, message))
391    }
392}
393
394impl Default for Email {
395    fn default() -> Self {
396        Self::new()
397    }
398}
399
400fn plain(body: &str) -> SinglePart {
401    SinglePart::builder()
402        .header(ContentType::TEXT_PLAIN)
403        .body(body.to_string())
404}
405
406fn html_part(body: &str) -> SinglePart {
407    SinglePart::builder()
408        .header(ContentType::TEXT_HTML)
409        .body(body.to_string())
410}
411
412fn content_type_for(filename: &str) -> ContentType {
413    let mime = mime_guess_lite(filename);
414    mime.parse::<ContentType>().unwrap_or(ContentType::TEXT_PLAIN)
415}
416
417fn mime_guess_lite(filename: &str) -> &'static str {
418    match filename
419        .rsplit('.')
420        .next()
421        .unwrap_or("")
422        .to_ascii_lowercase()
423        .as_str()
424    {
425        "png" => "image/png",
426        "jpg" | "jpeg" => "image/jpeg",
427        "gif" => "image/gif",
428        "pdf" => "application/pdf",
429        "txt" => "text/plain",
430        "html" | "htm" => "text/html",
431        "json" => "application/json",
432        "zip" => "application/zip",
433        _ => "application/octet-stream",
434    }
435}