Skip to main content

sova_mail/
client.rs

1//! [`Mail`] plugin builder and [`MailClient`] transport handle.
2
3use crate::email::Email;
4use crate::fake::FakeMail;
5use lettre::transport::smtp::authentication::Credentials;
6use lettre::transport::smtp::AsyncSmtpTransport;
7use lettre::{AsyncFileTransport, AsyncTransport, Tokio1Executor};
8use sova_core::{Error, Result};
9use std::path::PathBuf;
10use std::sync::Arc;
11
12#[cfg(feature = "templates")]
13type TemplatesSlot =
14    Arc<std::sync::RwLock<Option<Arc<sova_templates::MiniJinjaTemplates>>>>;
15
16enum Backend {
17    Smtp(AsyncSmtpTransport<Tokio1Executor>),
18    File(AsyncFileTransport<Tokio1Executor>),
19    Fake(FakeMail),
20}
21
22/// Plugin installer — call [`Self::client`] before `app.install` to share the handle (e.g. tasks).
23pub struct Mail {
24    backend: Backend,
25    default_from: Option<String>,
26    /// True when `.from()` / env set an address (toml must not overwrite).
27    from_explicit: bool,
28    fake: Option<FakeMail>,
29    #[cfg(feature = "templates")]
30    templates_slot: TemplatesSlot,
31}
32
33impl Mail {
34    fn bare(backend: Backend, default_from: Option<String>, fake: Option<FakeMail>) -> Self {
35        Self {
36            backend,
37            default_from,
38            from_explicit: false,
39            fake,
40            #[cfg(feature = "templates")]
41            templates_slot: Arc::new(std::sync::RwLock::new(None)),
42        }
43    }
44
45    pub(crate) fn is_from_explicit(&self) -> bool {
46        self.from_explicit
47    }
48
49    /// SMTP relay (STARTTLS via lettre `relay`). Use [`SmtpBuilder::build`] or install via [`From`].
50    pub fn smtp(host: impl Into<String>) -> SmtpBuilder {
51        SmtpBuilder {
52            host: host.into(),
53            port: None,
54            user: None,
55            pass: None,
56            from: None,
57        }
58    }
59
60    /// Record messages in memory (tests / local demo).
61    pub fn fake() -> Self {
62        let fake = FakeMail::new();
63        Self::bare(
64            Backend::Fake(fake.clone()),
65            Some("Sova <noreply@localhost>".into()),
66            Some(fake),
67        )
68    }
69
70    /// Write `.eml` files under `dir`.
71    pub fn file(dir: impl Into<PathBuf>) -> Self {
72        let dir = dir.into();
73        let transport = AsyncFileTransport::<Tokio1Executor>::new(dir);
74        Self::bare(
75            Backend::File(transport),
76            Some("Sova <noreply@localhost>".into()),
77            None,
78        )
79    }
80
81    /// Like [`Self::try_from_env`], panics on invalid SMTP URL / unknown mailer.
82    ///
83    /// Missing mailer + missing URL still yields fake (dev DX). Prefer
84    /// [`Self::try_from_env`] in `main` when you want `?`.
85    pub fn from_env() -> Self {
86        Self::try_from_env().unwrap_or_else(|e| {
87            panic!("mail: from_env failed: {e}");
88        })
89    }
90
91    /// Build from process env — see [`Self::try_from_vars`].
92    pub fn try_from_env() -> Result<Self> {
93        Self::try_from_vars(|k| std::env::var(k).ok().filter(|s| !s.is_empty()))
94    }
95
96    /// Build from a lookup fn (tests / custom env sources).
97    ///
98    /// - `SOVA_MAIL` / `SOVA_MAIL_MAILER`: `fake` | `smtp` | `file` (optional)
99    /// - No mailer + no URL → [`Self::fake`] (dev)
100    /// - `smtp` / URL set + parse failure → **`Err`** (never silently fakes)
101    /// - `file` → `SOVA_MAIL_PATH` (default `./mail`)
102    pub fn try_from_vars<F>(mut get: F) -> Result<Self>
103    where
104        F: FnMut(&str) -> Option<String>,
105    {
106        let from = get("SOVA_MAIL_FROM");
107        let mailer = get("SOVA_MAIL")
108            .or_else(|| get("SOVA_MAIL_MAILER"))
109            .map(|s| s.trim().to_ascii_lowercase())
110            .filter(|s| !s.is_empty());
111        let url = get("SOVA_MAIL_URL").or_else(|| get("SMTP_URL"));
112
113        let kind = match mailer.as_deref() {
114            Some("fake") => "fake",
115            Some("file") => "file",
116            Some("smtp") => "smtp",
117            Some(other) => {
118                return Err(Error::Internal(format!(
119                    "unknown SOVA_MAIL={other} (use fake|smtp|file)"
120                )));
121            }
122            None if url.is_some() => "smtp",
123            None => "fake",
124        };
125
126        match kind {
127            "fake" => {
128                if mailer.is_none() {
129                    tracing::info!("mail: no SOVA_MAIL / SOVA_MAIL_URL — using fake transport");
130                }
131                let mut m = Self::fake();
132                if let Some(f) = from {
133                    m.default_from = Some(f);
134                    m.from_explicit = true;
135                }
136                Ok(m)
137            }
138            "file" => {
139                let path = get("SOVA_MAIL_PATH").unwrap_or_else(|| "./mail".into());
140                let mut m = Self::file(path);
141                if let Some(f) = from {
142                    m.default_from = Some(f);
143                    m.from_explicit = true;
144                }
145                Ok(m)
146            }
147            "smtp" => {
148                let url = url.ok_or_else(|| {
149                    Error::Internal("SOVA_MAIL=smtp requires SOVA_MAIL_URL or SMTP_URL".into())
150                })?;
151                let transport = build_smtp_from_url(&url)?;
152                let mut m = Self::bare(
153                    Backend::Smtp(transport),
154                    from.clone().or_else(|| Some("Sova <noreply@localhost>".into())),
155                    None,
156                );
157                if from.is_some() {
158                    m.from_explicit = true;
159                }
160                Ok(m)
161            }
162            _ => unreachable!(),
163        }
164    }
165
166    pub fn from(mut self, addr: impl Into<String>) -> Self {
167        self.default_from = Some(addr.into());
168        self.from_explicit = true;
169        self
170    }
171
172    /// Shared client (clone before `install` for background jobs).
173    ///
174    /// With feature `templates`, the templates slot is shared with the installed
175    /// client — wiring at install / startup reaches earlier clones too.
176    pub fn client(&self) -> MailClient {
177        MailClient {
178            backend: match &self.backend {
179                Backend::Smtp(t) => Arc::new(ClientBackend::Smtp(t.clone())),
180                Backend::File(t) => Arc::new(ClientBackend::File(t.clone())),
181                Backend::Fake(f) => Arc::new(ClientBackend::Fake(f.clone())),
182            },
183            default_from: self.default_from.clone(),
184            fake: self.fake.clone(),
185            events: None,
186            #[cfg(feature = "templates")]
187            templates: Arc::clone(&self.templates_slot),
188        }
189    }
190
191    pub(crate) fn into_client(self) -> MailClient {
192        self.client()
193    }
194
195    /// Recorder when using [`Self::fake`].
196    pub fn recorder(&self) -> Option<&FakeMail> {
197        self.fake.as_ref()
198    }
199
200    pub(crate) fn from_smtp(b: SmtpBuilder) -> Self {
201        b.build().unwrap_or_else(|e| {
202            panic!("mail smtp build failed (refusing silent fake): {e}");
203        })
204    }
205}
206
207/// Fluent SMTP configuration → [`Mail`].
208pub struct SmtpBuilder {
209    host: String,
210    port: Option<u16>,
211    user: Option<String>,
212    pass: Option<String>,
213    from: Option<String>,
214}
215
216impl SmtpBuilder {
217    pub fn port(mut self, port: u16) -> Self {
218        self.port = Some(port);
219        self
220    }
221
222    pub fn credentials(mut self, user: impl Into<String>, pass: impl Into<String>) -> Self {
223        self.user = Some(user.into());
224        self.pass = Some(pass.into());
225        self
226    }
227
228    pub fn from(mut self, addr: impl Into<String>) -> Self {
229        self.from = Some(addr.into());
230        self
231    }
232
233    pub fn build(self) -> Result<Mail> {
234        let mut builder = AsyncSmtpTransport::<Tokio1Executor>::relay(&self.host)
235            .map_err(|e| Error::Internal(format!("mail smtp relay: {e}")))?;
236        if let Some(port) = self.port {
237            builder = builder.port(port);
238        }
239        if let (Some(user), Some(pass)) = (self.user.clone(), self.pass.clone()) {
240            builder = builder.credentials(Credentials::new(user, pass));
241        }
242        let transport = builder.build();
243        let explicit = self.from.is_some();
244        let mut mail = Mail::bare(
245            Backend::Smtp(transport),
246            self.from
247                .or_else(|| Some("Sova <noreply@localhost>".into())),
248            None,
249        );
250        mail.from_explicit = explicit;
251        Ok(mail)
252    }
253}
254
255impl From<SmtpBuilder> for Mail {
256    fn from(b: SmtpBuilder) -> Self {
257        Mail::from_smtp(b)
258    }
259}
260
261enum ClientBackend {
262    Smtp(AsyncSmtpTransport<Tokio1Executor>),
263    File(AsyncFileTransport<Tokio1Executor>),
264    Fake(FakeMail),
265}
266
267/// Shared outbound mail handle (`app.state` / clone into tasks).
268#[derive(Clone)]
269pub struct MailClient {
270    backend: Arc<ClientBackend>,
271    pub(crate) default_from: Option<String>,
272    fake: Option<FakeMail>,
273    events: Option<sova_core::EventBus>,
274    #[cfg(feature = "templates")]
275    templates: TemplatesSlot,
276}
277
278impl MailClient {
279    pub fn compose(&self) -> Email {
280        Email::with_client(self.clone())
281    }
282
283    /// Soft-wire [`EventBus`] for [`crate::MailSent`] emits after send.
284    pub fn set_events(&mut self, bus: sova_core::EventBus) {
285        self.events = Some(bus);
286    }
287
288    #[cfg(feature = "templates")]
289    pub(crate) fn templates(&self) -> Option<Arc<sova_templates::MiniJinjaTemplates>> {
290        self.templates.read().unwrap().clone()
291    }
292
293    #[cfg(feature = "templates")]
294    pub(crate) fn set_templates(&self, templates: sova_templates::MiniJinjaTemplates) {
295        *self.templates.write().unwrap() = Some(Arc::new(templates));
296    }
297
298    #[allow(unused_mut)] // mut needed when resolving deferred view/markdown bodies
299    pub async fn send(&self, mut email: Email) -> Result<()> {
300        #[cfg(any(feature = "templates", feature = "markdown"))]
301        email.resolve_body(self)?;
302        let (snap, message) = email.into_message(self.default_from.as_deref())?;
303        let to = snap.to.clone();
304        let subject = snap.subject.clone();
305        let result = match self.backend.as_ref() {
306            ClientBackend::Fake(fake) => {
307                fake.record(snap);
308                Ok(())
309            }
310            ClientBackend::Smtp(t) => t
311                .send(message)
312                .await
313                .map(|_| ())
314                .map_err(|e| Error::Internal(format!("mail smtp: {e}"))),
315            ClientBackend::File(t) => t
316                .send(message)
317                .await
318                .map(|_| ())
319                .map_err(|e| Error::Internal(format!("mail file: {e}"))),
320        };
321        if result.is_ok() {
322            if let Some(bus) = &self.events {
323                bus.dispatch(crate::MailSent { to, subject });
324            }
325        }
326        result
327    }
328
329    /// Fake inbox when installed via [`Mail::fake`].
330    pub fn fake(&self) -> Option<&FakeMail> {
331        self.fake.as_ref()
332    }
333}
334
335fn build_smtp_from_url(url: &str) -> Result<AsyncSmtpTransport<Tokio1Executor>> {
336    Ok(AsyncSmtpTransport::<Tokio1Executor>::from_url(url)
337        .map_err(|e| Error::Internal(format!("mail url: {e}")))?
338        .build())
339}