1mod 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#[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
72pub struct Mailer {
74 setup: Setup,
75 registered: Vec<Arc<Registered>>,
76 jobs: Jobs,
77}
78
79impl Mailer {
80 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 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 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}