Skip to main content

sova_mail/
lib.rs

1//! Outbound email for Sova (Express/Nodemailer-simple API on [lettre](https://lettre.rs/)).
2//!
3//! With feature `templates`, render MiniJinja views into the body (Laravel-style):
4//!
5//! ```ignore
6//! req.mail()
7//!     .to(user)
8//!     .subject("Welcome")
9//!     .view("mail/welcome.html", json!({ "name": name }))
10//!     .send()
11//!     .await?;
12//!
13//! // Mailable
14//! req.mail().to(user).send_mail(WelcomeMail { name }).await?;
15//!
16//! // Markdown body (feature `markdown`)
17//! req.mail().to(user).subject("Hi").markdown("# Hello\n\nWorld").send().await?;
18//! ```
19//!
20//! Layouts use Jinja `{% extends "mail/layout.html" %}` in the template file.
21
22mod client;
23mod email;
24mod events;
25mod fake;
26mod mailable;
27
28#[cfg(feature = "markdown")]
29mod markdown;
30
31pub use client::{Mail, MailClient, SmtpBuilder};
32pub use email::{Email, EmailSnapshot};
33pub use events::MailSent;
34pub use fake::FakeMail;
35pub use mailable::{Content, Envelope, Mailable};
36
37use sova_core::{App, Plugin, Request};
38
39/// `req.mail()` — start an [`Email`] with the installed client's default `From`.
40pub trait MailExt {
41    fn mail(&self) -> Email;
42}
43
44impl MailExt for Request {
45    fn mail(&self) -> Email {
46        let email = self.state::<MailClient>().compose();
47        #[cfg(feature = "templates")]
48        {
49            if let Some(templates) = self.try_state::<sova_templates::MiniJinjaTemplates>() {
50                return email.with_ambient(templates.freeze_ambient(self));
51            }
52        }
53        email
54    }
55}
56
57impl Plugin for Mail {
58    fn id(&self) -> &'static str {
59        "mail"
60    }
61
62    fn meta(&self) -> sova_core::PluginMeta {
63        sova_core::PluginMeta::new("Mail")
64            .description("Outbound email via lettre (SMTP / fake / file)")
65            .version(env!("CARGO_PKG_VERSION"))
66    }
67
68    fn install(self, app: &mut App) {
69        let mut mail = self;
70        // Unset-fill from `[mail]` — explicit `.from()` / env wins.
71        if !mail.is_from_explicit() {
72            if let Some(doc) = app.config_doc() {
73                if let Some(section) = doc.section("mail") {
74                    if let Some(from) = section.get("from").and_then(|v| v.as_str()) {
75                        mail = mail.from(from);
76                    }
77                }
78            }
79        }
80        let mut client = mail.into_client();
81        client.set_events(app.events());
82
83        #[cfg(feature = "templates")]
84        {
85            if let Some(t) = app.try_state::<sova_templates::MiniJinjaTemplates>() {
86                client.set_templates(t.as_ref().clone());
87            }
88            // If Templates is installed after Mail, pick it up before accept.
89            let wire = client.clone();
90            app.on_startup(move |state| {
91                let wire = wire.clone();
92                async move {
93                    if wire.templates().is_none() {
94                        if let Some(t) = state.get::<sova_templates::MiniJinjaTemplates>() {
95                            wire.set_templates(t.as_ref().clone());
96                        }
97                    }
98                    Ok(())
99                }
100            });
101        }
102
103        app.state(client);
104    }
105}
106
107impl Plugin for client::SmtpBuilder {
108    fn id(&self) -> &'static str {
109        "mail"
110    }
111
112    fn meta(&self) -> sova_core::PluginMeta {
113        sova_core::PluginMeta::new("Mail")
114            .description("Outbound email via lettre (SMTP / fake / file)")
115            .version(env!("CARGO_PKG_VERSION"))
116    }
117
118    fn install(self, app: &mut App) {
119        match self.build() {
120            Ok(mail) => mail.install(app),
121            Err(e) => panic!("mail smtp install failed (refusing silent fake): {e}"),
122        }
123    }
124}