Implemented mail notifications.

This commit is contained in:
2024-02-12 17:17:26 +01:00
parent d18ef72d03
commit 1b6f544e55
9 changed files with 720 additions and 78 deletions
+74
View File
@@ -10,6 +10,13 @@ cfg_if! { if #[cfg(feature = "ssr")] {
use crate::backend::get_pool;
use crate::error::AppError;
use log::info;
use crate::config::Mailing;
use crate::config::MailTransport;
use crate::backend::data::ResSumWithItems;
use lettre::message::Message as LettreMessage;
use lettre::{AsyncSmtpTransport, AsyncFileTransport, AsyncTransport, Tokio1Executor};
use lettre::transport::smtp::authentication::Credentials;
use std::ops::Add;
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")
@@ -48,6 +55,73 @@ cfg_if! { if #[cfg(feature = "ssr")] {
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#", &{
let mut sum = "".to_string();
for p in &reservation.reservations {
sum = sum.add(&format!("{}: {} - {}",
p.property.name,
p.reservation.from.format("%H:%M").to_string(), p.reservation.to.format("%H:%M").to_string()));
}
sum
}
)
}
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())?)
}
}
impl Mailing {
pub async fn send_mail(&self, msg: MailMessage) -> Result<(), AppError> {
match self.transport() {
MailTransport::Smtp => {
let transport = if self.tls().unwrap_or(false) {
AsyncSmtpTransport::<Tokio1Executor>::starttls_relay(&self.server().clone().unwrap_or_default())
} else {
AsyncSmtpTransport::<Tokio1Executor>::relay(&self.server().clone().unwrap_or_default())
}.expect("Cannot create SMTP mail transport");
if self.user().is_some() && self.password().is_some() {
let cred = Credentials::new(self.user().clone().unwrap(), self.password().clone().unwrap());
transport.credentials(cred).build().send(msg.build_mail(self.from().to_string())?).await?;
} else {
transport.build().send(msg.build_mail(self.from().to_string())?).await?;
}
}
MailTransport::File => {
AsyncFileTransport::<Tokio1Executor>::new(self.path().clone().unwrap_or_default())
.send(msg.build_mail(self.from().to_string())?).await?;
}
}
Ok(())
}
}
}}
#[server]
+15 -3
View File
@@ -54,27 +54,39 @@ cfg_if!{
use actix_web::web::Data;
use leptos_actix::extract;
use leptos::ServerFnError;
use crate::config::Mailing;
#[derive(Clone)]
pub struct AppData {
db_pool: PgPool
db_pool: PgPool,
mailer: Mailing
}
impl AppData {
pub fn new(db_pool: PgPool) -> Self {
pub fn new(db_pool: PgPool, mailer: Mailing) -> Self {
Self {
db_pool
db_pool,
mailer
}
}
pub fn db_pool(&self) -> &PgPool {
&self.db_pool
}
pub fn mailer(&self) -> &Mailing {
&self.mailer
}
}
pub async fn get_pool() -> Result<PgPool, ServerFnError> {
let data = extract::<Data<AppData>>().await?;
Ok(data.db_pool().clone())
}
pub async fn get_mailing() -> Result<Mailing, ServerFnError> {
let data = extract::<Data<AppData>>().await?;
Ok(data.mailer().clone())
}
}
}
+106 -15
View File
@@ -13,8 +13,16 @@ cfg_if! { if #[cfg(feature = "ssr")] {
use std::ops::DerefMut;
use std::str::FromStr;
use futures_util::future::join_all;
use crate::backend::data::{ReservationSum, ReservationState, ResWithProperty, Customer};
use log::warn;
use crate::backend::data::{ReservationSum, ReservationState, ResWithProperty, Customer, Message, MessageType};
use crate::backend::get_pool;
use crate::backend::get_mailing;
use crate::backend::mail::MailMessage;
use crate::backend::mail::get_message;
use crate::error::AppError;
use sqlx::PgPool;
use crate::backend::user::admin_email;
use crate::backend::user::emails_for_notify;
async fn find_sum_by_uuid(uuid: &Uuid, tx: &mut Transaction<'_, Postgres>) -> Result<ReservationSum, Error> {
let reservation = query_as::<_, ReservationSum>("SELECT * FROM reservation_sum WHERE uuid = $1")
@@ -43,6 +51,39 @@ cfg_if! { if #[cfg(feature = "ssr")] {
}
}
async fn reservation_by_uuid(uuid: Uuid) -> Result<ResSumWithItems, ServerFnError> {
let pool = get_pool().await?;
let summary = query_as::<_, ReservationSum>("SELECT * FROM reservation_sum WHERE uuid = $1")
.bind(uuid)
.fetch_one(&pool)
.await?;
let sum_id = summary.id();
let cust_id = summary.customer;
Ok(ResSumWithItems{
summary,
customer: customer_for_reservation(cust_id, &pool).await?,
reservations: items_for_reservation(sum_id, &pool).await?
})
}
async fn items_for_reservation(id: i32, pool: &PgPool) -> Result<Vec<ResWithProperty>, ServerFnError> {
Ok(query_as::<_, ResWithProperty>(
"SELECT r.id, r.from, r.to, r.property, r.summary, p.name, p,description \
FROM reservation as r \
JOIN property as p ON r.property = p.id WHERE r.summary = $1")
.bind(id)
.fetch_all(pool)
.await?)
}
async fn customer_for_reservation(id: i32, pool: &PgPool) -> Result<Customer, ServerFnError> {
Ok(query_as::<_, Customer>("SELECT * FROM customer WHERE id = $1")
.bind(id)
.fetch_one(pool)
.await?)
}
async fn reservations_in_range(from: &NaiveDate, to: &NaiveDate, state: Option<ReservationState>) -> Result<Vec<ResSumWithItems>, ServerFnError> {
let pool = get_pool().await?;
let sums = if let Some(s) = state {
@@ -74,18 +115,9 @@ cfg_if! { if #[cfg(feature = "ssr")] {
return Ok(vec![])
}
let res: Result<Vec<ResSumWithItems>, Error> = join_all(sums.into_iter().map(|s| async {
let reservations = query_as::<_, ResWithProperty>(
"SELECT r.id, r.from, r.to, r.property, r.summary, p.name, p,description \
FROM reservation as r \
JOIN property as p ON r.property = p.id WHERE r.summary = $1")
.bind(s.id())
.fetch_all(&pool)
.await?;
let customer = query_as::<_, Customer>("SELECT * FROM customer WHERE id = $1")
.bind(s.customer)
.fetch_one(&pool)
.await?;
let res: Result<Vec<ResSumWithItems>, ServerFnError> = join_all(sums.into_iter().map(|s| async {
let reservations = items_for_reservation(s.id(), &pool).await?;
let customer = customer_for_reservation(s.customer, &pool).await?;
Ok(ResSumWithItems {
summary: s,
customer,
@@ -106,6 +138,51 @@ cfg_if! { if #[cfg(feature = "ssr")] {
Ok(())
}
async fn notify_new_all(admin_mail: String, reservation: &ResSumWithItems) -> Result<(), AppError> {
let mailing = get_mailing().await?;
let msg = get_message(MessageType::NewReservation).await?;
for m in emails_for_notify().await? {
mailing.send_mail(MailMessage::new(admin_mail.clone(), m, msg.clone(), reservation)).await?;
}
Ok(())
}
async fn notify_new(uuid: Uuid) -> Result<(), AppError> {
let mailing = get_mailing().await?;
let msg = get_message(MessageType::NewReservationCust).await?;
let reservation = reservation_by_uuid(uuid).await?;
let admin_mail = admin_email().await;
if admin_mail.is_none() {
return Err(AppError::MailSendError("No admin mail".to_string()))
}
mailing.send_mail(MailMessage::new(admin_mail.clone().unwrap(), reservation.customer.email.clone(), msg, &reservation)).await?;
notify_new_all(admin_mail.unwrap(), &reservation).await
}
async fn send_notify(uuid: Uuid, msg: Message) -> Result<(), AppError> {
let mailing = get_mailing().await?;
let reservation = reservation_by_uuid(uuid).await?;
let admin_mail = admin_email().await;
if admin_mail.is_none() {
return Err(AppError::MailSendError("No admin mail".to_string()))
}
mailing.send_mail(MailMessage::new(admin_mail.unwrap(), reservation.customer.email.clone(), msg, &reservation)).await
}
async fn notify_approve(uuid: Uuid) -> Result<(), AppError> {
send_notify(uuid, get_message(MessageType::ReservationApp).await?).await
}
async fn notify_cancel(uuid: Uuid) -> Result<(), AppError> {
send_notify(uuid, get_message(MessageType::ReservationCanceled).await?).await
}
}}
#[server]
@@ -206,6 +283,10 @@ pub async fn create_reservation(reservation: CrReservation) -> Result<ApiRespons
tx.commit().await?;
if let Err(e) = notify_new(res_uuid).await {
warn!("Notification not send: {}", e);
}
Ok(ApiResponse::Data(reservation.date()))
}
@@ -247,7 +328,12 @@ pub async fn approve(uuid: String) -> Result<ApiResponse<()>, ServerFnError> {
use crate::backend::data::ReservationState;
perm_check!(is_logged_in);
set_state(Uuid::parse_str(&uuid)?, ReservationState::Approved).await?;
let uuid = Uuid::parse_str(&uuid)?;
set_state(uuid, ReservationState::Approved).await?;
if let Err(e) = notify_approve(uuid).await {
warn!("Approve notification not send: {}", e);
}
Ok(ApiResponse::Data(()))
}
@@ -258,7 +344,12 @@ pub async fn cancel(uuid: String) -> Result<ApiResponse<()>, ServerFnError> {
use crate::backend::data::ReservationState;
perm_check!(is_logged_in);
set_state(Uuid::parse_str(&uuid)?, ReservationState::Canceled).await?;
let uuid = Uuid::parse_str(&uuid)?;
set_state(uuid, ReservationState::Canceled).await?;
if let Err(e) = notify_cancel(uuid).await {
warn!("Cancel notification not send: {}", e);
}
Ok(ApiResponse::Data(()))
}
+31
View File
@@ -10,6 +10,7 @@ cfg_if! { if #[cfg(feature = "ssr")] {
use leptos_actix::{extract, redirect};
use log::{info, warn};
use crate::error::AppError;
use crate::backend::get_pool;
pub async fn has_admin_user(pool: &PgPool) -> Result<bool, Error> {
let count: (i64,) = query_as(r#"SELECT COUNT(id) FROM "user" WHERE admin = $1"#)
@@ -68,6 +69,36 @@ cfg_if! { if #[cfg(feature = "ssr")] {
false
}
}
pub async fn admin_email() -> Option<String> {
let pool = get_pool().await.ok()?;
let mail: Result<(String,), Error> = query_as(r#"SELECT email FROM "user" WHERE login = 'admin'"#)
.fetch_one(&pool)
.await;
if let Ok(m) = mail {
Some(m.0)
} else {
None
}
}
pub async fn emails_for_notify() -> Result<Vec<String>, ServerFnError> {
let pool = get_pool().await?;
let mails: Result<Vec<(String,)>, Error> = query_as(r#"SELECT email FROM "user" WHERE (email IS NOT NULL OR email <> '') AND get_emails = true"#)
.fetch_all(&pool)
.await;
if let Err(e) = mails {
if matches!(e, Error::RowNotFound) {
Ok(vec![])
} else {
Err(e.into())
}
} else {
Ok(mails.unwrap().into_iter().map(|m| m.0).collect())
}
}
}}
#[server]
+50 -1
View File
@@ -51,12 +51,58 @@ impl Database {
}
}
#[cfg(feature = "ssr")]
#[derive(Deserialize, Clone)]
pub enum MailTransport {
Smtp,
File
}
#[cfg(feature = "ssr")]
#[derive(Deserialize, Clone)]
pub struct Mailing {
transport: MailTransport,
from: String,
path: Option<String>,
server: Option<String>,
user: Option<String>,
password: Option<String>,
tls: Option<bool>
}
#[cfg(feature = "ssr")]
impl Mailing {
pub fn transport(&self) -> &MailTransport {
&self.transport
}
pub fn from(&self) -> &str {
&self.from
}
pub fn path(&self) -> &Option<String> {
&self.path
}
pub fn server(&self) -> &Option<String> {
&self.server
}
pub fn user(&self) -> &Option<String> {
&self.user
}
pub fn password(&self) -> &Option<String> {
&self.password
}
pub fn tls(&self) -> Option<bool> {
self.tls
}
}
#[cfg(feature = "ssr")]
#[derive(Deserialize)]
pub struct Configuration {
session: Session,
network: Network,
database: Database
database: Database,
mailing: Mailing
}
#[cfg(feature = "ssr")]
@@ -70,6 +116,9 @@ impl Configuration {
pub fn database(&self) -> &Database {
&self.database
}
pub fn mailing(&self) -> &Mailing {
&self.mailing
}
}
#[cfg(feature = "ssr")]
+34 -2
View File
@@ -8,7 +8,9 @@ pub enum AppError {
HourParseError,
ServerError(String),
FatalError(String),
SlotParseError
SlotParseError,
MailAddrParseErr(String),
MailSendError(String)
}
impl AppError {
@@ -17,7 +19,9 @@ impl AppError {
AppError::HourParseError => {"Hour parse error".to_string()},
AppError::ServerError(e) => {format!("Server error: {}", e)},
AppError::FatalError(e) => {format!("Fatal error: {}", e)},
AppError::SlotParseError => {"Book slot parse error".to_string()}
AppError::SlotParseError => {"Book slot parse error".to_string()},
AppError::MailAddrParseErr(e) => {format!("Cannot parse email address: {}", e)},
AppError::MailSendError(e) => {format!("Cannot send email: {}", e)}
}
}
}
@@ -48,3 +52,31 @@ impl From<ParseError> for AppError {
AppError::HourParseError
}
}
#[cfg(feature = "ssr")]
impl From<lettre::address::AddressError> for AppError {
fn from(value: lettre::address::AddressError) -> Self {
AppError::MailAddrParseErr(value.to_string())
}
}
#[cfg(feature = "ssr")]
impl From<lettre::error::Error> for AppError {
fn from(value: lettre::error::Error) -> Self {
AppError::MailSendError(value.to_string())
}
}
#[cfg(feature = "ssr")]
impl From<lettre::transport::smtp::Error> for AppError {
fn from(value: lettre::transport::smtp::Error) -> Self {
AppError::MailSendError(value.to_string())
}
}
#[cfg(feature = "ssr")]
impl From<lettre::transport::file::Error> for AppError {
fn from(value: lettre::transport::file::Error) -> Self {
AppError::MailSendError(value.to_string())
}
}
+3 -2
View File
@@ -51,9 +51,10 @@ async fn main() -> std::io::Result<()> {
let pool = PgPoolOptions::new()
.max_connections(10)
.connect(&srv_conf.database().con_string()).await.unwrap();
migrate!().run(&pool).await.expect("could not run SQLx migrations");
let mailing = srv_conf.mailing().clone();
if let Err(e) = create_admin(&pool).await {
error!("Error while checking admin user: {:?}", e);
}
@@ -69,7 +70,7 @@ async fn main() -> std::io::Result<()> {
let site_root = &leptos_options.site_root;
App::new()
.app_data(Data::new(AppData::new(pool.clone())))
.app_data(Data::new(AppData::new(pool.clone(), mailing.clone())))
.wrap(Authentication)
.wrap(SessionMiddleware::new(
CookieSessionStore::default(),