1use 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
22pub struct Mail {
24 backend: Backend,
25 default_from: Option<String>,
26 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 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 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 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 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 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 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 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 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
207pub 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#[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 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)] 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 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}