use cfg_if::cfg_if; use leptos::prelude::*; use validator::Validate; use crate::backend::data::{ApiResponse, Message, MessageType}; use crate::components::data_form::ForValidation; cfg_if! { if #[cfg(feature = "ssr")] { use sqlx::{PgPool, query, query_as}; use sqlx::Error; use crate::backend::get_pool; use crate::error::AppError; use log::info; use log::error; use crate::config::Mailing; use crate::config::MailTransport; use crate::backend::data::ResSumWithItems; use lettre::message::Message as LettreMessage; use lettre::{FileTransport, SmtpTransport, Transport}; use lettre::transport::smtp::client::{Tls, TlsParameters}; use lettre::transport::smtp::authentication::Credentials; use lettre::transport::smtp::extension::ClientId; use std::sync::mpsc::Sender; use std::sync::mpsc; pub async fn message_for_type(msg_type: &MessageType, pool: &PgPool) -> Result { Ok(query_as::<_, Message>("SELECT * FROM message WHERE msg_type = $1") .bind(msg_type) .fetch_one(pool) .await?) } pub async fn init_message(msg_type: &MessageType, pool: &PgPool) -> Result<(), Error> { query("INSERT INTO message(msg_type, subject, text) VALUES($1, '', '')") .bind(msg_type) .execute(pool) .await?; Ok(()) } pub async fn check_messages(pool: &PgPool) -> Result<(), AppError> { let types = [ MessageType::NewReservation, MessageType::NewReservationCust, MessageType::ReservationApp, MessageType::ReservationCanceled]; for msg_type in types { let msg = message_for_type(&msg_type, pool).await; if let Err(e) = msg { if matches!(e, Error::RowNotFound) { info!("Creating initial message for type {:?}", msg_type); init_message(&msg_type, pool).await?; } else { return Err(e.into()) } } } Ok(()) } pub struct MailMessage { reply_to: String, to: String, subject: String, text: String } impl MailMessage { pub fn new(reply_to: String, to: String, message: Message, reservation: &ResSumWithItems) -> Self { Self { reply_to, to, subject: message.subject, text: Self::replace_body_vars(message.text, &reservation) } } fn replace_body_vars(text: String, reservation: &ResSumWithItems) -> String { text .replace("#date#", &reservation.summary.date.format("%d. %m. %Y").to_string()) .replace("#summary#", &reservation.reservations.iter() .map(|p| format!("{}: {} - {}", p.property.name, p.reservation.from.format("%H:%M"), p.reservation.to.format("%H:%M"))) .collect::>() .join("\n") ) } pub fn build_mail(&self, from: String) -> Result { Ok(LettreMessage::builder() .from(from.parse()?) .reply_to(self.reply_to.parse()?) .to(self.to.parse()?) .subject(&self.subject) .body(self.text.clone())?) } } #[derive(Clone)] enum MailerType { Smtp(SmtpTransport), File(String) } #[derive(Clone)] pub struct Mailer { transport: MailerType, from: String, sender: Option> } impl Mailer { pub fn new(config: &Mailing) -> Self { match config.transport() { MailTransport::Smtp => { let tls = if let Some(t) = config.accept_all_certs() { if t { let tls = TlsParameters::builder(config.server().clone().unwrap_or_default()) .dangerous_accept_invalid_certs(true) .dangerous_accept_invalid_hostnames(true); Some(tls.build().expect("Cannot build TLS params")) } else { None } } else { None }; let transport = if config.tls().unwrap_or(false) { let transport = SmtpTransport::starttls_relay(&config.server().clone().unwrap_or_default()) .expect("Cannot create SMTP mail transport"); if let Some(t) = tls { transport.tls(Tls::Required(t)) } else { transport } } else { let transport = SmtpTransport::relay(&config.server().clone().unwrap_or_default()) .expect("Cannot create SMTP mail transport"); if let Some(t) = tls { transport.tls(Tls::Wrapper(t)) } else { transport } }; let transport = if let Some(p) = config.port() { transport.port(p) } else { transport }; let transport = if let Some(hello) = config.hello_name() { transport.hello_name(ClientId::Domain(hello.to_string())) } else { transport }; if config.user().is_some() && config.password().is_some() { let cred = Credentials::new(config.user().clone().unwrap(), config.password().clone().unwrap()); Self { transport: MailerType::Smtp(transport.credentials(cred).build()), from: config.from().to_string(), sender: None } } else { Self { transport: MailerType::Smtp(transport.build()), from: config.from().to_string(), sender: None } } } MailTransport::File => { Self { transport: MailerType::File(config.path().clone().unwrap_or("".to_string())), from: config.from().to_string(), sender: None } } } } fn send(&self, msg: &MailMessage) -> Result<(), AppError> { let to_send = msg.build_mail(self.from.clone())?; match &self.transport { MailerType::Smtp(s) => {s.send(&to_send)?;}, MailerType::File(s) => {FileTransport::new(s).send(&to_send)?;} } Ok(()) } pub fn start_sender(&mut self) { let (tx, rx) = mpsc::channel::(); self.sender = Some(tx); let mailer = self.clone(); std::thread::spawn(move || { loop { let msg = rx.recv(); if let Err(e) = msg { error!("Mailer error: {}", e.to_string()); break; } let msg = msg.unwrap(); if let Err(e) = mailer.send(&msg) { error!("Mail send error: {}", e); } else { info!("Mail message for: {} has been successfully sent", msg.to); } } }); info!("Mail sender started"); } pub fn send_mail(&self, msg: MailMessage) -> Result<(), AppError> { if let Err(e) = self.sender.as_ref().expect("Sender not started").send(msg) { error!("Mail queue error: {}", e); Err(AppError::MailSendError(e.to_string())) } else { info!("Message queued for send"); Ok(()) } } } }} #[server] pub async fn get_message(msg_type: MessageType) -> Result { let pool = get_pool().await?; Ok(message_for_type(&msg_type, &pool).await?) } #[server] pub async fn update_message(message: Message) -> Result, ServerFnError> { use crate::perm_check; perm_check!(is_admin); let pool = get_pool().await?; query("UPDATE message SET subject = $1, text = $2 WHERE msg_type = $3") .bind(message.subject) .bind(message.text) .bind(message.msg_type) .execute(&pool) .await?; Ok(ApiResponse::Data(())) } impl ForValidation for UpdateMessage { fn entity(&self) -> &dyn Validate { &self.message } } #[cfg(all(test, feature = "ssr"))] mod tests { use super::*; use chrono::{NaiveDate, NaiveTime}; use crate::backend::data::{Reservation, ResProperty, ResWithProperty}; fn item(name: &str, from: (u32, u32), to: (u32, u32)) -> ResWithProperty { let mut property = ResProperty::default(); property.name = name.to_string(); ResWithProperty { reservation: Reservation::new( NaiveTime::from_hms_opt(from.0, from.1, 0).unwrap(), NaiveTime::from_hms_opt(to.0, to.1, 0).unwrap(), 1), property } } fn reservation() -> ResSumWithItems { let mut reservation = ResSumWithItems::default(); reservation.summary.date = NaiveDate::from_ymd_opt(2030, 3, 5).unwrap(); reservation.reservations.push(item("Court 1", (8, 0), (9, 30))); reservation } fn message(subject: &str, text: &str) -> Message { let mut message = Message::default(); message.subject = subject.to_string(); message.text = text.to_string(); message } #[test] fn mail_message_replaces_variables() { let mail = MailMessage::new( "admin@example.com".to_string(), "customer@example.com".to_string(), message("Booking", "Date: #date#, items: #summary#."), &reservation()); assert_eq!(mail.subject, "Booking"); assert_eq!(mail.text, "Date: 05. 03. 2030, items: Court 1: 08:00 - 09:30."); } #[test] fn mail_message_summary_has_one_item_per_line() { let mut reservation = reservation(); reservation.reservations.push(item("Court 2", (10, 0), (11, 0))); reservation.reservations.push(item("Sauna", (18, 0), (20, 0))); let mail = MailMessage::new( "admin@example.com".to_string(), "customer@example.com".to_string(), message("Booking", "Items:\n#summary#\nBye"), &reservation); assert_eq!(mail.text, "Items:\nCourt 1: 08:00 - 09:30\nCourt 2: 10:00 - 11:00\nSauna: 18:00 - 20:00\nBye"); } #[test] fn mail_message_summary_is_empty_without_items() { let mail = MailMessage::new( "admin@example.com".to_string(), "customer@example.com".to_string(), message("Booking", "Items: #summary#."), &ResSumWithItems::default()); assert_eq!(mail.text, "Items: ."); } #[test] fn mail_message_without_variables_is_unchanged() { let mail = MailMessage::new( "admin@example.com".to_string(), "customer@example.com".to_string(), message("Booking", "Plain text"), &reservation()); assert_eq!(mail.text, "Plain text"); } #[test] fn build_mail_sets_headers() { let mail = MailMessage::new( "admin@example.com".to_string(), "customer@example.com".to_string(), message("Booking", "Hello"), &reservation()); let built = String::from_utf8(mail.build_mail("noreply@example.com".to_string()).unwrap().formatted()).unwrap(); assert!(built.contains("From: noreply@example.com")); assert!(built.contains("Reply-To: admin@example.com")); assert!(built.contains("To: customer@example.com")); assert!(built.contains("Subject: Booking")); } #[test] fn build_mail_rejects_invalid_address() { let mail = MailMessage::new( "admin@example.com".to_string(), "not an address".to_string(), message("Booking", "Hello"), &reservation()); assert!(matches!(mail.build_mail("noreply@example.com".to_string()), Err(AppError::MailAddrParseErr(_)))); } }