Skip to main content

sova_mail/
mailable.rs

1//! Laravel-style [`Mailable`] — envelope + content applied onto [`Email`].
2
3use crate::email::Email;
4use sova_core::Result;
5
6/// Message metadata (subject / from / cc / bcc). Recipients stay on [`Email::to`].
7#[derive(Clone, Debug, Default)]
8pub struct Envelope {
9    pub subject: String,
10    pub from: Option<String>,
11    pub cc: Vec<String>,
12    pub bcc: Vec<String>,
13}
14
15impl Envelope {
16    pub fn new(subject: impl Into<String>) -> Self {
17        Self {
18            subject: subject.into(),
19            ..Default::default()
20        }
21    }
22
23    pub fn from(mut self, addr: impl Into<String>) -> Self {
24        self.from = Some(addr.into());
25        self
26    }
27
28    pub fn cc(mut self, addr: impl Into<String>) -> Self {
29        self.cc.push(addr.into());
30        self
31    }
32
33    pub fn bcc(mut self, addr: impl Into<String>) -> Self {
34        self.bcc.push(addr.into());
35        self
36    }
37}
38
39#[derive(Clone)]
40enum ContentInner {
41    Html {
42        html: String,
43        text: Option<String>,
44    },
45    Text(String),
46    #[cfg(feature = "templates")]
47    View {
48        name: String,
49        ctx: serde_json::Value,
50        text: Option<String>,
51    },
52    #[cfg(feature = "markdown")]
53    Markdown {
54        md: String,
55        /// When true, also set plain-text body to the raw markdown source.
56        with_text: bool,
57    },
58    #[cfg(all(feature = "templates", feature = "markdown"))]
59    MarkdownView {
60        name: String,
61        ctx: serde_json::Value,
62        with_text: bool,
63    },
64}
65
66/// Body of a [`Mailable`] (html / text / view / markdown).
67#[derive(Clone)]
68pub struct Content {
69    inner: ContentInner,
70}
71
72impl Content {
73    pub fn html(html: impl Into<String>) -> Self {
74        Self {
75            inner: ContentInner::Html {
76                html: html.into(),
77                text: None,
78            },
79        }
80    }
81
82    pub fn html_with_text(html: impl Into<String>, text: impl Into<String>) -> Self {
83        Self {
84            inner: ContentInner::Html {
85                html: html.into(),
86                text: Some(text.into()),
87            },
88        }
89    }
90
91    pub fn text(text: impl Into<String>) -> Self {
92        Self {
93            inner: ContentInner::Text(text.into()),
94        }
95    }
96
97    /// MiniJinja HTML view (feature `templates`).
98    #[cfg(feature = "templates")]
99    pub fn view<T: serde::Serialize>(name: impl Into<String>, ctx: T) -> Self {
100        let ctx = serde_json::to_value(ctx).unwrap_or(serde_json::Value::Null);
101        Self {
102            inner: ContentInner::View {
103                name: name.into(),
104                ctx,
105                text: None,
106            },
107        }
108    }
109
110    /// Like [`Self::view`], plus a plain-text alternative.
111    #[cfg(feature = "templates")]
112    pub fn view_with_text<T: serde::Serialize>(
113        name: impl Into<String>,
114        ctx: T,
115        text: impl Into<String>,
116    ) -> Self {
117        let ctx = serde_json::to_value(ctx).unwrap_or(serde_json::Value::Null);
118        Self {
119            inner: ContentInner::View {
120                name: name.into(),
121                ctx,
122                text: Some(text.into()),
123            },
124        }
125    }
126
127    /// Raw markdown → HTML at send (feature `markdown`).
128    #[cfg(feature = "markdown")]
129    pub fn markdown(md: impl Into<String>) -> Self {
130        Self {
131            inner: ContentInner::Markdown {
132                md: md.into(),
133                with_text: true,
134            },
135        }
136    }
137
138    /// MiniJinja template whose output is markdown, then converted to HTML.
139    #[cfg(all(feature = "templates", feature = "markdown"))]
140    pub fn markdown_view<T: serde::Serialize>(name: impl Into<String>, ctx: T) -> Self {
141        let ctx = serde_json::to_value(ctx).unwrap_or(serde_json::Value::Null);
142        Self {
143            inner: ContentInner::MarkdownView {
144                name: name.into(),
145                ctx,
146                with_text: true,
147            },
148        }
149    }
150
151    pub(crate) fn apply(self, mut email: Email) -> Email {
152        match self.inner {
153            ContentInner::Html { html, text } => {
154                email = email.html(html);
155                if let Some(t) = text {
156                    email = email.text(t);
157                }
158                email
159            }
160            ContentInner::Text(text) => email.text(text),
161            #[cfg(feature = "templates")]
162            ContentInner::View { name, ctx, text } => {
163                email = email.view(name, ctx);
164                if let Some(t) = text {
165                    email = email.text(t);
166                }
167                email
168            }
169            #[cfg(feature = "markdown")]
170            ContentInner::Markdown { md, with_text } => {
171                email = email.markdown(md);
172                if !with_text {
173                    // markdown() also sets text; clear if caller wanted HTML-only
174                }
175                let _ = with_text;
176                email
177            }
178            #[cfg(all(feature = "templates", feature = "markdown"))]
179            ContentInner::MarkdownView {
180                name,
181                ctx,
182                with_text,
183            } => {
184                let _ = with_text;
185                email.markdown_view(name, ctx)
186            }
187        }
188    }
189}
190
191/// Laravel-style mailable: describe envelope + content, send via [`Email::send_mail`].
192pub trait Mailable: Send + Sync {
193    fn envelope(&self) -> Envelope;
194    fn content(&self) -> Content;
195
196    /// Apply this mailable onto a started [`Email`] (already has `to` / client).
197    fn build(&self, mut email: Email) -> Email {
198        let env = self.envelope();
199        email = email.subject(env.subject);
200        if let Some(from) = env.from {
201            email = email.from(from);
202        }
203        for addr in env.cc {
204            email = email.cc(addr);
205        }
206        for addr in env.bcc {
207            email = email.bcc(addr);
208        }
209        self.content().apply(email)
210    }
211}
212
213impl Email {
214    /// Apply a [`Mailable`] and [`Self::send`].
215    pub async fn send_mail<M: Mailable>(self, mail: M) -> Result<()> {
216        mail.build(self).send().await
217    }
218}
219
220impl crate::client::MailClient {
221    /// `compose().to(to).send_mail(mail)`.
222    pub async fn send_mail<M: Mailable>(
223        &self,
224        to: impl Into<String>,
225        mail: M,
226    ) -> Result<()> {
227        self.compose().to(to).send_mail(mail).await
228    }
229}