Skip to main content

shared/
mail.rs

1use crate::{models::user::User, settings::SettingsReadGuard};
2use lettre::AsyncTransport;
3use std::{collections::BTreeMap, sync::Arc};
4
5#[derive(Debug)]
6enum Transport {
7    None,
8    Smtp {
9        transport: lettre::AsyncSmtpTransport<lettre::Tokio1Executor>,
10        from_address: compact_str::CompactString,
11        from_name: Option<compact_str::CompactString>,
12    },
13    Sendmail {
14        transport: lettre::AsyncSendmailTransport<lettre::Tokio1Executor>,
15        from_address: compact_str::CompactString,
16        from_name: Option<compact_str::CompactString>,
17    },
18    Filesystem {
19        transport: lettre::AsyncFileTransport<lettre::Tokio1Executor>,
20        from_address: compact_str::CompactString,
21        from_name: Option<compact_str::CompactString>,
22    },
23}
24
25impl Transport {
26    async fn deliver(
27        self,
28        destination: &str,
29        subject: String,
30        body: String,
31    ) -> Result<(), anyhow::Error> {
32        let message = move |from_address: &str,
33                            from_name: Option<compact_str::CompactString>|
34              -> Result<lettre::message::Message, anyhow::Error> {
35            Ok(lettre::message::Message::builder()
36                .subject(subject)
37                .to(lettre::message::Mailbox::new(None, destination.parse()?))
38                .from(lettre::message::Mailbox::new(
39                    from_name.map(String::from),
40                    from_address.parse()?,
41                ))
42                .header(lettre::message::header::ContentType::TEXT_HTML)
43                .body(body)?)
44        };
45
46        match self {
47            Transport::None => {}
48            Transport::Smtp {
49                transport,
50                from_address,
51                from_name,
52            } => {
53                transport.send(message(&from_address, from_name)?).await?;
54            }
55            Transport::Sendmail {
56                transport,
57                from_address,
58                from_name,
59            } => {
60                transport.send(message(&from_address, from_name)?).await?;
61            }
62            Transport::Filesystem {
63                transport,
64                from_address,
65                from_name,
66            } => {
67                transport.send(message(&from_address, from_name)?).await?;
68            }
69        }
70
71        Ok(())
72    }
73}
74
75pub struct MailRecipient {
76    pub address: compact_str::CompactString,
77    pub language: Option<compact_str::CompactString>,
78}
79
80impl MailRecipient {
81    pub fn new(
82        address: impl Into<compact_str::CompactString>,
83        language: impl Into<compact_str::CompactString>,
84    ) -> Self {
85        Self {
86            address: address.into(),
87            language: Some(language.into()),
88        }
89    }
90}
91
92impl From<compact_str::CompactString> for MailRecipient {
93    fn from(address: compact_str::CompactString) -> Self {
94        Self {
95            address,
96            language: None,
97        }
98    }
99}
100
101impl From<&User> for MailRecipient {
102    fn from(user: &User) -> Self {
103        Self {
104            address: user.email.clone(),
105            language: Some(user.language.clone()),
106        }
107    }
108}
109
110struct RenderedMail {
111    subject: String,
112    body: String,
113}
114
115fn render_variables(
116    environment: &mut minijinja::Environment<'_>,
117    variables: &BTreeMap<compact_str::CompactString, String>,
118    context: &minijinja::Value,
119    html: bool,
120) -> Result<(), anyhow::Error> {
121    environment.add_global(
122        "vars",
123        minijinja::Value::from_object(BTreeMap::<minijinja::Value, minijinja::Value>::new()),
124    );
125    let first_pass = render_variables_pass(environment, variables, context, html)?;
126    environment.add_global("vars", first_pass);
127
128    let second_pass = render_variables_pass(environment, variables, context, html)?;
129    environment.add_global("vars", second_pass);
130
131    Ok(())
132}
133
134fn render_variables_pass(
135    environment: &minijinja::Environment<'_>,
136    variables: &BTreeMap<compact_str::CompactString, String>,
137    context: &minijinja::Value,
138    html: bool,
139) -> Result<minijinja::Value, anyhow::Error> {
140    let mut rendered = BTreeMap::new();
141
142    for (name, source) in variables {
143        let value = environment
144            .render_str(source, context)
145            .map_err(|err| anyhow::anyhow!("failed to render email variable '{name}': {err}"))?;
146
147        rendered.insert(
148            minijinja::Value::from(name.as_str()),
149            if html {
150                minijinja::Value::from_safe_string(value)
151            } else {
152                minijinja::Value::from(value)
153            },
154        );
155    }
156
157    Ok(minijinja::Value::from_object(rendered))
158}
159
160fn render(
161    settings: &impl serde::Serialize,
162    language: &str,
163    variables: &BTreeMap<compact_str::CompactString, String>,
164    subject: &str,
165    body: &str,
166    context: &minijinja::Value,
167) -> Result<RenderedMail, anyhow::Error> {
168    let settings = minijinja::Value::from(minijinja::value::Serde(settings));
169
170    let mut text_environment = minijinja::Environment::new();
171    text_environment.add_global("settings", settings.clone());
172    text_environment.add_global("language", language);
173    render_variables(&mut text_environment, variables, context, false)?;
174
175    let subject = text_environment.render_str(subject, context)?;
176
177    let mut html_environment = minijinja::Environment::new();
178    html_environment.set_auto_escape_callback(|_| minijinja::AutoEscape::Html);
179    html_environment.add_global("settings", settings);
180    html_environment.add_global("language", language);
181    html_environment.add_global("subject", subject.as_str());
182    render_variables(&mut html_environment, variables, context, true)?;
183
184    let body = html_environment.render_str(body, context)?;
185
186    Ok(RenderedMail { subject, body })
187}
188
189pub struct Mail {
190    settings: Arc<super::settings::Settings>,
191    pub templates: Arc<super::extensions::email_templates::EmailTemplateManager>,
192}
193
194impl Mail {
195    pub fn new(settings: Arc<super::settings::Settings>) -> Self {
196        Self {
197            settings,
198            templates: Arc::new(
199                super::extensions::email_templates::EmailTemplateManager::default(),
200            ),
201        }
202    }
203
204    async fn get_transport(&self) -> Result<(SettingsReadGuard<'_>, Transport), anyhow::Error> {
205        let settings = self.settings.get().await?;
206
207        let transport = match &settings.mail_mode {
208            super::settings::MailMode::None => Transport::None,
209            super::settings::MailMode::Smtp {
210                host,
211                port,
212                username,
213                password,
214                tls_mode,
215                skip_cert_validation,
216                helo_domain,
217                from_address,
218                from_name,
219            } => {
220                let mut transport =
221                    lettre::AsyncSmtpTransport::<lettre::Tokio1Executor>::builder_dangerous(
222                        host.as_str(),
223                    )
224                    .port(*port)
225                    .tls(match tls_mode {
226                        super::settings::TlsMode::None => {
227                            lettre::transport::smtp::client::Tls::None
228                        }
229                        super::settings::TlsMode::StartTls => {
230                            lettre::transport::smtp::client::Tls::Required(
231                                lettre::transport::smtp::client::TlsParametersBuilder::new(
232                                    host.to_string(),
233                                )
234                                .dangerous_accept_invalid_certs(*skip_cert_validation)
235                                .build_rustls()?,
236                            )
237                        }
238                        super::settings::TlsMode::ImplicitTls => {
239                            lettre::transport::smtp::client::Tls::Wrapper(
240                                lettre::transport::smtp::client::TlsParametersBuilder::new(
241                                    host.to_string(),
242                                )
243                                .dangerous_accept_invalid_certs(*skip_cert_validation)
244                                .build_rustls()?,
245                            )
246                        }
247                    });
248
249                if let Some(helo_domain) = helo_domain {
250                    transport = transport.hello_name(match helo_domain.parse() {
251                        Ok(std::net::IpAddr::V4(ip)) => {
252                            lettre::transport::smtp::extension::ClientId::Ipv4(ip)
253                        }
254                        Ok(std::net::IpAddr::V6(ip)) => {
255                            lettre::transport::smtp::extension::ClientId::Ipv6(ip)
256                        }
257                        Err(_) => lettre::transport::smtp::extension::ClientId::Domain(
258                            helo_domain.to_string(),
259                        ),
260                    });
261                }
262
263                if let Some(username) = username {
264                    transport = transport.credentials(
265                        lettre::transport::smtp::authentication::Credentials::new(
266                            username.to_string(),
267                            password.clone().unwrap_or_default().into(),
268                        ),
269                    );
270                }
271
272                Transport::Smtp {
273                    transport: transport.build(),
274                    from_address: from_address.clone(),
275                    from_name: from_name.clone(),
276                }
277            }
278            super::settings::MailMode::Sendmail {
279                command,
280                from_address,
281                from_name,
282            } => {
283                let transport =
284                    lettre::AsyncSendmailTransport::<lettre::Tokio1Executor>::new_with_command(
285                        command,
286                    );
287
288                Transport::Sendmail {
289                    transport,
290                    from_address: from_address.clone(),
291                    from_name: from_name.clone(),
292                }
293            }
294            super::settings::MailMode::Filesystem {
295                path,
296                from_address,
297                from_name,
298            } => {
299                let transport = lettre::AsyncFileTransport::<lettre::Tokio1Executor>::new(path);
300
301                Transport::Filesystem {
302                    transport,
303                    from_address: from_address.clone(),
304                    from_name: from_name.clone(),
305                }
306            }
307        };
308
309        Ok((settings, transport))
310    }
311
312    async fn language(
313        &self,
314        recipient: &MailRecipient,
315    ) -> Result<compact_str::CompactString, anyhow::Error> {
316        match &recipient.language {
317            Some(language) => Ok(language.clone()),
318            None => Ok(self.settings.get().await?.app.language.clone()),
319        }
320    }
321
322    /// Sending a disabled template is a silent no-op, so flows that depend on the mail arriving
323    /// must check this rather than trust the `Ok(())` from a send.
324    pub async fn template_deliverable(
325        &self,
326        state: &crate::State,
327        identifier: &str,
328    ) -> Result<bool, anyhow::Error> {
329        if matches!(
330            self.settings.get().await?.mail_mode,
331            super::settings::MailMode::None
332        ) {
333            return Ok(false);
334        }
335
336        Ok(self
337            .templates
338            .get_template(identifier)?
339            .get(state)
340            .await?
341            .enabled)
342    }
343
344    pub async fn send_template_foreground(
345        &self,
346        state: &crate::State,
347        identifier: &str,
348        recipient: impl Into<MailRecipient>,
349        context: minijinja::Value,
350    ) -> Result<(), anyhow::Error> {
351        let recipient = recipient.into();
352        let template = self.templates.get_template(identifier)?;
353        let fetched_template = template.get(state).await?;
354
355        if !fetched_template.enabled {
356            tracing::debug!(
357                "email template '{}' is disabled, skipping sending email",
358                identifier
359            );
360            return Ok(());
361        }
362
363        let language = self.language(&recipient).await?;
364        let variables = self
365            .templates
366            .resolve_variables(state, identifier, &language)
367            .await?;
368
369        self.send_rendered_foreground(
370            recipient,
371            &language,
372            &variables,
373            &fetched_template.subject,
374            &fetched_template.content,
375            context,
376        )
377        .await
378    }
379
380    pub async fn send_template(
381        &self,
382        state: &crate::State,
383        identifier: &str,
384        recipient: impl Into<MailRecipient>,
385        context: minijinja::Value,
386    ) {
387        let recipient = recipient.into();
388        let template = match self.templates.get_template(identifier) {
389            Ok(template) => template,
390            Err(err) => {
391                tracing::error!("failed to get email template: {:#?}", err);
392                return;
393            }
394        };
395        let fetched_template = match template.get(state).await {
396            Ok(template) => template,
397            Err(err) => {
398                tracing::error!("failed to get email template content: {:#?}", err);
399                return;
400            }
401        };
402
403        if !fetched_template.enabled {
404            tracing::debug!(
405                "email template '{}' is disabled, skipping sending email",
406                identifier
407            );
408            return;
409        }
410
411        let language = match self.language(&recipient).await {
412            Ok(language) => language,
413            Err(err) => {
414                tracing::error!("failed to resolve email language: {:#?}", err);
415                return;
416            }
417        };
418        let variables = match self
419            .templates
420            .resolve_variables(state, identifier, &language)
421            .await
422        {
423            Ok(variables) => variables,
424            Err(err) => {
425                tracing::error!("failed to resolve email variables: {:#?}", err);
426                return;
427            }
428        };
429
430        self.send_rendered(
431            recipient,
432            &language,
433            &variables,
434            &fetched_template.subject,
435            &fetched_template.content,
436            context,
437        )
438        .await
439    }
440
441    /// Sends a one-off mail that is not backed by a template, so no `vars` are available to it.
442    pub async fn send_foreground(
443        &self,
444        recipient: impl Into<MailRecipient>,
445        subject: impl AsRef<str>,
446        body: impl AsRef<str>,
447        context: minijinja::Value,
448    ) -> Result<(), anyhow::Error> {
449        let recipient = recipient.into();
450        let language = self.language(&recipient).await?;
451
452        self.send_rendered_foreground(
453            recipient,
454            &language,
455            &BTreeMap::new(),
456            subject.as_ref(),
457            body.as_ref(),
458            context,
459        )
460        .await
461    }
462
463    /// Sends a one-off mail that is not backed by a template, so no `vars` are available to it.
464    pub async fn send(
465        &self,
466        recipient: impl Into<MailRecipient>,
467        subject: impl AsRef<str>,
468        body: impl AsRef<str>,
469        context: minijinja::Value,
470    ) {
471        let recipient = recipient.into();
472        let language = match self.language(&recipient).await {
473            Ok(language) => language,
474            Err(err) => {
475                tracing::error!("failed to resolve email language: {:#?}", err);
476                return;
477            }
478        };
479
480        self.send_rendered(
481            recipient,
482            &language,
483            &BTreeMap::new(),
484            subject.as_ref(),
485            body.as_ref(),
486            context,
487        )
488        .await
489    }
490
491    async fn send_rendered_foreground(
492        &self,
493        recipient: MailRecipient,
494        language: &str,
495        variables: &BTreeMap<compact_str::CompactString, String>,
496        subject: &str,
497        body: &str,
498        context: minijinja::Value,
499    ) -> Result<(), anyhow::Error> {
500        let (settings, transport) = self.get_transport().await?;
501        let rendered = render(&*settings, language, variables, subject, body, &context)?;
502        drop(settings);
503
504        transport
505            .deliver(&recipient.address, rendered.subject, rendered.body)
506            .await
507    }
508
509    async fn send_rendered(
510        &self,
511        recipient: MailRecipient,
512        language: &str,
513        variables: &BTreeMap<compact_str::CompactString, String>,
514        subject: &str,
515        body: &str,
516        context: minijinja::Value,
517    ) {
518        let (settings, transport) = match self.get_transport().await {
519            Ok((settings, transport)) => (settings, transport),
520            Err(err) => {
521                tracing::error!("failed to get mail transport: {:#?}", err);
522                return;
523            }
524        };
525
526        let rendered = match render(&*settings, language, variables, subject, body, &context) {
527            Ok(rendered) => rendered,
528            Err(err) => {
529                tracing::error!(
530                    transport = ?transport,
531                    destination = ?recipient.address,
532                    "error while rendering email template: {:?}",
533                    err
534                );
535
536                return;
537            }
538        };
539        drop(settings);
540
541        tracing::debug!(
542            transport = ?transport,
543            destination = ?recipient.address,
544            "sending email"
545        );
546
547        tokio::spawn(async move {
548            match transport
549                .deliver(&recipient.address, rendered.subject, rendered.body)
550                .await
551            {
552                Ok(_) => tracing::debug!("email sent successfully"),
553                Err(err) => tracing::error!("failed to send email: {:?}", err),
554            }
555        });
556    }
557}
558
559#[cfg(test)]
560mod tests {
561    use super::*;
562
563    fn settings() -> serde_json::Value {
564        serde_json::json!({ "app": { "name": "Panel & Co", "url": "https://panel.example.com" } })
565    }
566
567    fn variables(pairs: &[(&str, &str)]) -> BTreeMap<compact_str::CompactString, String> {
568        pairs
569            .iter()
570            .map(|(name, value)| ((*name).into(), (*value).to_string()))
571            .collect()
572    }
573
574    #[test]
575    fn variable_markup_is_kept_while_interpolations_are_escaped() {
576        let context = minijinja::context! {
577            user => minijinja::value::Serde(serde_json::json!({ "username": "<script>alert(1)</script>" })),
578            link => "https://panel.example.com/reset?token=a&b=<c>",
579        };
580        let variables = variables(&[
581            ("subject", "Reset for <b>{{ user.username }}</b>"),
582            ("greeting", "Hello <strong>{{ user.username }}</strong>,"),
583            (
584                "intro",
585                "Your panel <em>{{ settings.app.name }}</em> ({{ language }})",
586            ),
587            ("button", "<a href=\"{{ link }}\">Reset</a>"),
588            (
589                "conditional",
590                "{% if user.username %}has user{% else %}no user{% endif %}",
591            ),
592            ("nested", "before {{ vars.greeting }} after"),
593            ("deep", "[{{ vars.nested }}]"),
594        ]);
595
596        let rendered = render(
597            &settings(),
598            "de",
599            &variables,
600            "{{ settings.app.name }} - {{ vars.subject }}",
601            "<title>{{ subject }}</title>\n<p>{{ vars.greeting }}</p>\n<p>{{ vars.intro }}</p>\n<p>{{ vars.button }}</p>\n<p>{{ vars.conditional }}</p>\n<p>{{ vars.nested }}</p>\n<p>{{ vars.deep }}</p>\n<p>{{ vars.missing }}</p>\n<p>{{ user.username }}</p>",
602            &context,
603        )
604        .unwrap();
605
606        assert_eq!(
607            rendered.subject,
608            "Panel & Co - Reset for <b><script>alert(1)</script></b>"
609        );
610
611        let body = rendered.body;
612        assert!(body.contains("<title>Panel &amp; Co - Reset for &lt;b&gt;&lt;script&gt;alert(1)&lt;&#x2f;script&gt;&lt;&#x2f;b&gt;</title>"));
613        assert!(
614            body.contains(
615                "<p>Hello <strong>&lt;script&gt;alert(1)&lt;&#x2f;script&gt;</strong>,</p>"
616            )
617        );
618        assert!(body.contains("<p>Your panel <em>Panel &amp; Co</em> (de)</p>"));
619        assert!(body.contains("<p><a href=\"https:&#x2f;&#x2f;panel.example.com&#x2f;reset?token=a&amp;b=&lt;c&gt;\">Reset</a></p>"));
620        assert!(body.contains("<p>has user</p>"));
621        assert!(body.contains(
622            "<p>before Hello <strong>&lt;script&gt;alert(1)&lt;&#x2f;script&gt;</strong>, after</p>"
623        ));
624        assert!(body.contains("<p>[before  after]</p>"));
625        assert!(body.contains("<p></p>"));
626        assert!(body.contains("<p>&lt;script&gt;alert(1)&lt;&#x2f;script&gt;</p>"));
627        assert!(!body.contains("<script>"));
628    }
629
630    #[test]
631    fn variable_render_errors_name_the_variable() {
632        let err = render(
633            &settings(),
634            "en",
635            &variables(&[("broken", "{% if %}")]),
636            "s",
637            "b",
638            &minijinja::context! {},
639        )
640        .err()
641        .expect("broken variable must fail rendering");
642
643        assert!(err.to_string().contains("email variable 'broken'"), "{err}");
644    }
645}