371 lines
13 KiB
Rust
371 lines
13 KiB
Rust
use cfg_if::cfg_if;
|
|||
|
|
use leptos::*;
|
||
|
|
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<Message, Error> {
|
||
|
|
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::<Vec<_>>()
|
||
|
|
.join("\n")
|
||
)
|
|||
|
|
}
|
||
|
|
|
||
|
|
pub fn build_mail(&self, from: String) -> Result<LettreMessage, AppError> {
|
||
|
|
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<Sender<MailMessage>>
|
||
|
|
}
|
||
|
|
|
||
|
|
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::<MailMessage>();
|
||
|
|
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<Message, ServerFnError> {
|
||
|
|
let pool = get_pool().await?;
|
||
|
|
|
||
|
|
Ok(message_for_type(&msg_type, &pool).await?)
|
||
|
|
}
|
||
|
|
|
||
|
|
#[server]
|
||
|
|
pub async fn update_message(message: Message) -> Result<ApiResponse<()>, 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(
|
||
|
|
"[email protected]".to_string(),
|
||
|
|
"[email protected]".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(
|
||
|
|
"[email protected]".to_string(),
|
||
|
|
"[email protected]".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(
|
||
|
|
"[email protected]".to_string(),
|
||
|
|
"[email protected]".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(
|
||
|
|
"[email protected]".to_string(),
|
||
|
|
"[email protected]".to_string(),
|
||
|
|
message("Booking", "Plain text"),
|
||
|
|
&reservation());
|
||
|
|
|
||
|
|
assert_eq!(mail.text, "Plain text");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn build_mail_sets_headers() {
|
||
|
|
let mail = MailMessage::new(
|
||
|
|
"[email protected]".to_string(),
|
||
|
|
"[email protected]".to_string(),
|
||
|
|
message("Booking", "Hello"),
|
||
|
|
&reservation());
|
||
|
|
|
||
|
|
let built = String::from_utf8(mail.build_mail("[email protected]".to_string()).unwrap().formatted()).unwrap();
|
||
|
|
|
||
|
|
assert!(built.contains("From: [email protected]"));
|
||
|
|
assert!(built.contains("Reply-To: [email protected]"));
|
||
|
|
assert!(built.contains("To: [email protected]"));
|
||
|
|
assert!(built.contains("Subject: Booking"));
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn build_mail_rejects_invalid_address() {
|
||
|
|
let mail = MailMessage::new(
|
||
|
|
"[email protected]".to_string(),
|
||
|
|
"not an address".to_string(),
|
||
|
|
message("Booking", "Hello"),
|
||
|
|
&reservation());
|
||
|
|
|
||
|
|
assert!(matches!(mail.build_mail("[email protected]".to_string()), Err(AppError::MailAddrParseErr(_))));
|
||
|
|
}
|
||
|
|
}
|