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 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 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 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 & Co - Reset for <b><script>alert(1)</script></b></title>"));
613 assert!(
614 body.contains(
615 "<p>Hello <strong><script>alert(1)</script></strong>,</p>"
616 )
617 );
618 assert!(body.contains("<p>Your panel <em>Panel & Co</em> (de)</p>"));
619 assert!(body.contains("<p><a href=\"https://panel.example.com/reset?token=a&b=<c>\">Reset</a></p>"));
620 assert!(body.contains("<p>has user</p>"));
621 assert!(body.contains(
622 "<p>before Hello <strong><script>alert(1)</script></strong>, after</p>"
623 ));
624 assert!(body.contains("<p>[before after]</p>"));
625 assert!(body.contains("<p></p>"));
626 assert!(body.contains("<p><script>alert(1)</script></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}