Implemented mail notifications.
This commit is contained in:
@@ -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
@@ -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
@@ -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(()))
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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(),
|
||||
|
||||
Reference in New Issue
Block a user