Skip to main content

sofie_mail/
lib.rs

1//! Mail rendered from templates, queued with a handler's commit and sent by
2//! a job, so a request never waits on a mail server.
3
4mod delivery;
5mod mail;
6mod settings;
7mod smtp;
8mod suppression;
9mod templates;
10#[cfg(feature = "testing")]
11pub mod testing;
12mod text;
13mod transport;
14mod unsubscribe;
15#[cfg(feature = "web")]
16pub mod web;
17
18use std::sync::Arc;
19
20use sofie_core::{Error, Result, Show};
21use sofie_db::worker::Worker;
22use sofie_db::{Db, Dep, Extensions, Jobs, Queued, Resolve, Writes};
23
24pub use mail::{Address, Body, Mail};
25pub use settings::{Security, SmtpSettings};
26pub use suppression::{Reason, Scope, Suppression, scopes, suppress};
27pub use templates::{Template, templates};
28pub use unsubscribe::PATH as UNSUBSCRIBE_PATH;
29
30use settings::{Chosen, Settings};
31use templates::Registered;
32use transport::Transport;
33
34/// What every mail is sent with: `sofie::run` reads it from `SOFIE_MAIL_*`, sofie-testing makes a fake.
35#[derive(Clone)]
36pub struct Setup {
37    from: Address,
38    base_url: Option<String>,
39    transport: Arc<dyn Transport>,
40}
41
42impl Setup {
43    #[doc(hidden)]
44    pub fn from_environment() -> Result<Self> {
45        let settings = Settings::read(sofie_core::environment_variable)?;
46        let transport: Arc<dyn Transport> = match settings.transport {
47            Chosen::Smtp(smtp) => {
48                Arc::new(smtp::Smtp::new(smtp, settings.from.domain().to_owned()))
49            }
50            Chosen::Log { preview } => Arc::new(transport::Logged { preview }),
51        };
52        Ok(Self {
53            from: settings.from,
54            base_url: settings.base_url,
55            transport,
56        })
57    }
58}
59
60#[doc(hidden)]
61pub fn install(db: &Db, setup: &Setup) {
62    sofie_db::provide(db, Arc::new(setup.clone()));
63    sofie_db::name_job(db, delivery::JOB, delivery::deliver);
64}
65
66#[doc(hidden)]
67#[must_use]
68pub fn worker(worker: Worker) -> Worker {
69    worker.job(delivery::JOB, delivery::deliver)
70}
71
72/// Only resolved as an argument: a handler's mails are sent once its outcome commits.
73pub struct Mailer {
74    setup: Setup,
75    registered: Vec<Arc<Registered>>,
76    jobs: Jobs,
77}
78
79impl Mailer {
80    /// The app's template at `src/mails/<name>.html` of a module, keyed `<module>/<name>`.
81    pub fn body(&self, key: &str, data: &(impl Show + ?Sized)) -> Result<Body> {
82        let template = templates::find(&self.registered, key).ok_or_else(|| {
83            Error::internal_message(format!(
84                "there is no mail template `{key}`; write it as `src/mails/<name>.html` \
85                 in its module and run `sofie gen`"
86            ))
87        })?;
88        template.render(data)
89    }
90
91    /// A crate's own template, unless the app replaces it with one of the same key.
92    pub fn body_from(&self, template: &Template, data: &(impl Show + ?Sized)) -> Result<Body> {
93        templates::find(&self.registered, template.key())
94            .unwrap_or(template)
95            .render(data)
96    }
97
98    pub fn link(&self, path: &str) -> Result<String> {
99        let base = self.setup.base_url.as_deref().ok_or_else(|| {
100            settings::missing_base_url("links in mails start with the app's public URL")
101        })?;
102        if !path.starts_with('/') || path.starts_with("//") {
103            return Err(Error::internal_message(format!(
104                "a link in a mail names a path of this app, not `{path}`"
105            )));
106        }
107        Ok(format!("{base}{path}"))
108    }
109
110    /// Queued with the handler's outcome; from `SOFIE_MAIL_FROM` unless the mail names a sender.
111    pub fn send(&self, mail: Mail) -> Result<Queued> {
112        let mail = mail.with_default_sender(&self.setup.from).checked()?;
113        self.jobs.queue(delivery::deliver, &mail)
114    }
115}
116
117impl<C: Writes> Resolve<C> for Mailer {
118    async fn resolve(context: &mut C) -> Result<Self> {
119        let setup = installed(context).await?;
120        let registered = Extensions::<Registered>::resolve(context).await?;
121        Ok(Self {
122            setup,
123            registered: registered.to_vec(),
124            jobs: Jobs::resolve(context).await?,
125        })
126    }
127}
128
129async fn installed<C: Writes>(context: &mut C) -> Result<Setup> {
130    let setup = Dep::<Setup>::resolve(context).await.map_err(|_| {
131        Error::internal_message(
132            "mail is not set up: `sofie::run` sets it up from `SOFIE_MAIL_*` with sofie's `mail` \
133             feature, and sofie-testing with its own `mail` feature",
134        )
135    })?;
136    Ok(Setup::clone(&setup))
137}