From c0d8eddd303ac3d4d1745805b6fa19af50ceb93d Mon Sep 17 00:00:00 2001 From: Mo8it Date: Sat, 25 Feb 2023 18:08:21 +0100 Subject: [PATCH] Working field config!!! --- src/config.rs | 117 +++++++++++++++++++++++------ src/forms.rs | 62 +++++++++++++-- src/mailer.rs | 58 +++++++++----- src/main.rs | 2 +- src/routes.rs | 50 ++++++------ src/states.rs | 12 ++- src/templates.rs | 8 +- templates/contact_form.askama.html | 96 +++++++++++++---------- 8 files changed, 286 insertions(+), 119 deletions(-) diff --git a/src/config.rs b/src/config.rs index e474e31..2a980ac 100644 --- a/src/config.rs +++ b/src/config.rs @@ -4,12 +4,19 @@ use std::{env, fs::File, io::BufReader}; /// Email server credentials. #[derive(Deserialize)] -pub struct EmailServer { - pub server_name: String, - pub email: String, +pub struct EmailCredentials { + pub domain: String, + pub username: String, pub password: String, } +#[derive(Deserialize)] +pub struct Email { + pub from: String, + pub to: String, + pub credentials: EmailCredentials, +} + /// UTC offset for time formatting. #[derive(Deserialize)] pub struct UtcOffset { @@ -17,6 +24,22 @@ pub struct UtcOffset { pub minutes: i8, } +#[derive(Deserialize)] +#[serde(tag = "type")] +pub enum CustomFieldType { + Text, + Textarea { rows: u8 }, +} + +#[derive(Deserialize)] +pub struct CustomField { + pub id: String, + pub label: String, + #[serde(default)] + pub required_feedback: Option, + pub field_type: CustomFieldType, +} + /// Error messages for localization. #[derive(Deserialize)] pub struct ErrorMessages { @@ -24,11 +47,46 @@ pub struct ErrorMessages { pub email_error: String, } -/// Field localization strings in a form with label and invalid feedback on wrong field input. +fn default_name_label() -> String { + "Name".to_string() +} +fn default_name_required_feedback() -> String { + "Please enter your name".to_string() +} #[derive(Deserialize)] -pub struct Field { +pub struct NameField { + #[serde(default = "default_name_label")] pub label: String, - pub invalid_feedback: String, + #[serde(default = "default_name_required_feedback")] + pub required_feedback: String, +} + +fn default_email_label() -> String { + "Email".to_string() +} +fn default_email_required_feedback() -> String { + "Please enter your email".to_string() +} +#[derive(Deserialize)] +pub struct EmailField { + #[serde(default = "default_email_label")] + pub label: String, + #[serde(default = "default_email_required_feedback")] + pub required_feedback: String, +} + +fn default_captcha_label() -> String { + "Enter the code above".to_string() +} +fn default_captcha_required_feedback() -> String { + "Please enter code from the image above".to_string() +} +#[derive(Deserialize)] +pub struct CaptchaField { + #[serde(default = "default_captcha_label")] + pub label: String, + #[serde(default = "default_captcha_required_feedback")] + pub required_feedback: String, } /// Localization strings. @@ -36,34 +94,41 @@ pub struct Field { pub struct Strings { pub description: String, pub title: String, - pub name_field: Field, - pub email_field: Field, - /// No invalid feedback because it is optional. - pub telefon_field_label: String, - pub message_field: Field, - pub captcha_field: Field, + pub optional: String, pub submit: String, pub success: String, + pub message_from: String, + pub name_field: NameField, + pub email_field: EmailField, + pub captcha_field: CaptchaField, + pub error_messages: ErrorMessages, +} + +fn default_lang() -> String { + "en".to_string() +} + +#[derive(Deserialize)] +pub struct StateConfig { + /// The path prefix of all routes. + pub path_prefix: String, + pub custom_fields: Vec, + /// The language tag of the HTML file. + #[serde(default = "default_lang")] + pub lang: String, + pub strings: Strings, } /// Configuration. #[derive(Deserialize)] pub struct Config { - /// The language tag of the HTML file. - pub lang: String, - /// The path prefix of all routes. - pub path_prefix: String, /// The server socket address including port. pub socket_address: String, - pub email_server: EmailServer, - /// From mailbox. - pub email_from: String, - /// To mailbox. - pub email_to: String, + pub email: Email, pub log_file: String, pub utc_offset: UtcOffset, - pub error_messages: ErrorMessages, - pub strings: Strings, + #[serde(flatten)] + pub state_config: StateConfig, } impl Config { @@ -77,9 +142,13 @@ impl Config { let file = File::open(&config_path) .with_context(|| format!("Can not open the config file at the path {config_path}"))?; let reader = BufReader::new(file); - let config = + let mut config: Self = serde_yaml::from_reader(reader).context("Can not parse the YAML config file!")?; + // Add a space at the end for the email subject. + config.state_config.strings.message_from = + config.state_config.strings.message_from.trim().to_string() + " "; + Ok(config) } } diff --git a/src/forms.rs b/src/forms.rs index 2db51a4..b2869de 100644 --- a/src/forms.rs +++ b/src/forms.rs @@ -1,13 +1,26 @@ +use anyhow::{Context, Result}; use serde::Deserialize; +use std::collections::HashMap; + +use crate::config::CustomField; /// Fields of the contact form that persist after a redirection /// (example after failed server side validation). -#[derive(Deserialize, Default)] -pub struct PersistantContactFormFields { +#[derive(Deserialize)] +pub struct PersistantFieldContents { pub name: String, pub email: String, - pub telefon: String, - pub message: String, + pub custom: Vec, +} + +impl PersistantFieldContents { + pub fn new_empty(custom_fields: &Vec) -> Self { + Self { + name: String::default(), + email: String::default(), + custom: custom_fields.iter().map(|_| String::default()).collect(), + } + } } /// The contact form. @@ -16,6 +29,45 @@ pub struct ContactForm { /// The id for the captcha. pub id: u16, #[serde(flatten)] - pub persistant_fields: PersistantContactFormFields, + pub persistant_field_contents: PersistantFieldContents, pub captcha_answer: String, } + +impl ContactForm { + pub fn from_map( + mut map: HashMap, + custom_fields: &Vec, + ) -> Result { + let id = map + .get("id") + .context("id missing in the submitted form!")? + .parse() + .context("Failed to parse the submitted id!")?; + let captcha_answer = map + .remove("captcha_answer") + .context("captcha_answer missing in the submitted form!")?; + let name = map + .remove("name") + .context("name missing in the submitted form!")?; + let email = map + .remove("email") + .context("email missing in the submitted form!")?; + let mut custom = Vec::with_capacity(custom_fields.len()); + for field in custom_fields.iter() { + custom.push( + map.remove(&field.id) + .with_context(|| format!("{} missing in the submitted form!", &field.id))?, + ); + } + + Ok(Self { + id, + persistant_field_contents: PersistantFieldContents { + name, + email, + custom, + }, + captcha_answer, + }) + } +} diff --git a/src/mailer.rs b/src/mailer.rs index af74aa2..34957f6 100644 --- a/src/mailer.rs +++ b/src/mailer.rs @@ -7,8 +7,12 @@ use lettre::{ }, Tokio1Executor, }; +use tracing::info; -use crate::config::Config; +use crate::{ + config::{Config, StateConfig}, + forms::PersistantFieldContents, +}; type ASmtpTransport = AsyncSmtpTransport; @@ -23,12 +27,12 @@ impl Mailer { pub async fn build(config: &Config) -> Result { // Mail server credentials for login. let credentials = Credentials::new( - config.email_server.email.clone(), - config.email_server.password.clone(), + config.email.credentials.username.clone(), + config.email.credentials.password.clone(), ); // Establish the connection. - let mailer = ASmtpTransport::relay(&config.email_server.server_name) + let mailer = ASmtpTransport::relay(&config.email.credentials.domain) .context("Failed to connect to the email server!")? .credentials(credentials) .build(); @@ -36,17 +40,20 @@ impl Mailer { .test_connection() .await .context("Email connection test failed!")?; + info!("Successful email connection!"); // Set the From and To mailboxes for every sent mail. let message_builder = Message::builder() .from( config - .email_from + .email + .from .parse() .context("Failed to parse the From mailbox!")?, ) .to(config - .email_to + .email + .to .parse() .context("Failed to parse the To mailbox!")?); @@ -57,23 +64,40 @@ impl Mailer { } /// Sends a mail with data from the contact form. - pub async fn send(&self, name: &str, email: &str, telefon: &str, message: &str) -> Result<()> { - let name = name.trim().to_string(); - let subject = "Message from ".to_string() + &name; + pub async fn send( + &self, + persistant_field_contents: &PersistantFieldContents, + config: &StateConfig, + ) -> Result<()> { + let name = persistant_field_contents.name.trim().to_string(); + let subject = config.strings.message_from.clone() + &name; let reply_to = Mailbox::new( Some(name), - email + persistant_field_contents + .email .parse() .context("Failed to parse the email from the form to an address!")?, ); - let telefon = telefon.trim(); - let message = message.trim().to_string(); - let body = if !telefon.is_empty() { - message + "\n\nTelefon: " + telefon - } else { - message - }; + let default_fields_content = format!( + "{}: {}\n\n{}: {}\n\n\n", + config.strings.name_field.label, + persistant_field_contents.name, + config.strings.email_field.label, + persistant_field_contents.email + ); + let body = config + .custom_fields + .iter() + .map(|field| &field.label) + .zip( + persistant_field_contents + .custom + .iter() + .map(|content| content.trim()), + ) + .map(|(label, content)| format!("{label}:\n{content}\n\n\n")) + .fold(default_fields_content, |acc, s| acc + &s); let email = self .message_builder diff --git a/src/main.rs b/src/main.rs index 17e6699..7e05ff3 100644 --- a/src/main.rs +++ b/src/main.rs @@ -31,7 +31,7 @@ async fn init(logger_initialized: &mut bool) -> Result<()> { *logger_initialized = true; // The path prefix of all routes. - let path_prefix = config.path_prefix.clone(); + let path_prefix = config.state_config.path_prefix.clone(); let socket_address = config .socket_address diff --git a/src/routes.rs b/src/routes.rs index 81edcee..9de4ae8 100644 --- a/src/routes.rs +++ b/src/routes.rs @@ -4,23 +4,26 @@ use axum::{ extract::{Form, State}, response::Response, }; -use std::sync::{Arc, Mutex}; +use std::{ + collections::HashMap, + sync::{Arc, Mutex}, +}; use tracing::{error, info}; use crate::{ captcha_solutions::CaptchaSolutions, - config::Config, + config::StateConfig, errors::AppError, - forms::{ContactForm, PersistantContactFormFields}, + forms::{ContactForm, PersistantFieldContents}, mailer::Mailer, templates, }; /// Parameters to render the contact form. pub struct ContactFormParams<'a> { - config: Arc, + config: Arc, captcha_solutions: Arc>, - persistant_fields: Option, + persistant_field_contents: Option, error_message: Option<&'a str>, } @@ -40,13 +43,16 @@ pub fn render_contact_form(params: ContactFormParams<'_>) -> Result) -> Result>, + State(config): State>, State(captcha_solutions): State>>, ) -> Result { info!("Visited get(index)"); @@ -62,18 +68,20 @@ pub async fn index( render_contact_form(ContactFormParams { config, captcha_solutions, - persistant_fields: None, + persistant_field_contents: None, error_message: None, }) } /// Submit handler. pub async fn submit( - State(config): State>, + State(config): State>, State(captcha_solutions): State>>, State(mailer): State>, - Form(form): Form, + Form(form): Form>, ) -> Result { + let form = ContactForm::from_map(form, &config.custom_fields)?; + let right_captcha_answer = captcha_solutions .lock() .unwrap() @@ -85,27 +93,19 @@ pub async fn submit( return render_contact_form(ContactFormParams { config: Arc::clone(&config), captcha_solutions, - persistant_fields: Some(form.persistant_fields), - error_message: Some(&config.error_messages.captcha_error), + persistant_field_contents: Some(form.persistant_field_contents), + error_message: Some(&config.strings.error_messages.captcha_error), }); } - if let Err(e) = mailer - .send( - &form.persistant_fields.name, - &form.persistant_fields.email, - &form.persistant_fields.telefon, - &form.persistant_fields.message, - ) - .await - { + if let Err(e) = mailer.send(&form.persistant_field_contents, &config).await { error!("{e:?}"); return render_contact_form(ContactFormParams { config: Arc::clone(&config), captcha_solutions, - persistant_fields: Some(form.persistant_fields), - error_message: Some(&config.error_messages.email_error), + persistant_field_contents: Some(form.persistant_field_contents), + error_message: Some(&config.strings.error_messages.email_error), }); } @@ -113,7 +113,7 @@ pub async fn submit( } /// Called on successful contact form submission. -pub fn success(config: Arc) -> Result { +pub fn success(config: Arc) -> Result { info!("Successful contact form submission"); // Initialize template. diff --git a/src/states.rs b/src/states.rs index da546cd..d47c5f8 100644 --- a/src/states.rs +++ b/src/states.rs @@ -2,12 +2,16 @@ use anyhow::Result; use axum::extract::FromRef; use std::sync::{Arc, Mutex}; -use crate::{captcha_solutions::CaptchaSolutions, config::Config, mailer::Mailer}; +use crate::{ + captcha_solutions::CaptchaSolutions, + config::{Config, StateConfig}, + mailer::Mailer, +}; /// The application state. #[derive(Clone, FromRef)] pub struct AppState { - pub config: Arc, + pub config: Arc, pub mailer: Arc, pub captcha_solutions: Arc>, } @@ -17,12 +21,12 @@ impl AppState { pub async fn build(config: Config) -> Result { let mailer = Arc::new(Mailer::build(&config).await?); - let config = Arc::new(config); + let state_config = Arc::new(config.state_config); // Mutex for write access. let captcha_solutions = Arc::new(Mutex::new(CaptchaSolutions::default())); Ok(Self { - config, + config: state_config, mailer, captcha_solutions, }) diff --git a/src/templates.rs b/src/templates.rs index 47bd51d..fd2d51b 100644 --- a/src/templates.rs +++ b/src/templates.rs @@ -1,6 +1,9 @@ use askama::Template; -use crate::{config, forms::PersistantContactFormFields}; +use crate::{ + config::{self, CustomField}, + forms::PersistantFieldContents, +}; /// Base template. pub struct Base<'a> { @@ -15,10 +18,11 @@ pub struct ContactForm<'a> { pub base: Base<'a>, pub was_validated: bool, pub id: u16, - pub persistant_fields: PersistantContactFormFields, + pub persistant_field_contents: PersistantFieldContents, pub captcha: String, pub error_message: &'a str, pub strings: &'a config::Strings, + pub custom_fields: &'a Vec, } /// Sucessful contact form submission template. diff --git a/templates/contact_form.askama.html b/templates/contact_form.askama.html index d21e7e8..9e97b7c 100644 --- a/templates/contact_form.askama.html +++ b/templates/contact_form.askama.html @@ -16,62 +16,76 @@ -
{{ strings.name_field.invalid_feedback }}
+
{{ strings.name_field.required_feedback }}
-
{{ strings.email_field.invalid_feedback }}
-
-
- - -
-
- - -
{{ strings.message_field.invalid_feedback }}
+
{{ strings.email_field.required_feedback }}
-
- -
-
- - -
{{ strings.captcha_field.invalid_feedback }}
-
+ {% for custom_field in custom_fields %} +
+ {% let value = persistant_field_contents.custom[loop.index0] %} + {% let required = custom_field.required_feedback.is_some() %} - {% if error_message.len() > 0 %} - - {% endif %} + -
- -
- + {% match custom_field.field_type %} + {% when config::CustomFieldType::Text %} + + {% when config::CustomFieldType::Textarea with { rows } %} + + {% endmatch %} + + {% match custom_field.required_feedback %} + {% when Some with (feedback) %} +
{{ feedback }}
+ {% when None %} + {% endmatch %} +
+{% endfor %} + +
+ +
+
+ + +
{{ strings.captcha_field.required_feedback }}
+
+ +{% if error_message.len() > 0 %} + +{% endif %} + +
+ +
+ {% endblock %} {% block scripts %}