Files
bakit/src/settings.rs
T
phundrak 797ab461ab feat: send confirmation email to sender
When users submit a contact form, they now receive a confirmation
email acknowlledging receipt of their message. The backend also
continues to send a notification email to the configured recipient.

If the backend fails to send the acknowledgement email to the sender,
it will assume the email is not valid and will therefore not transmit
the contact request to the configured recipient.

Changes:
- Refactor `send_email()` to `send_emails()` that sends two emails:
  - Confirmation email from the submitter
  - Notification email to the configured recipient
- Add `From<T>` implementations of various errors for new error type
  `ContactError`.
- Errors now return a translation identifier for the frontend.
2025-11-16 02:12:21 +01:00

722 lines
22 KiB
Rust

//! Application configuration settings.
//!
//! This module provides configuration structures that can be loaded from:
//! - YAML configuration files (base.yaml and environment-specific files)
//! - Environment variables (prefixed with APP__)
//!
//! Settings include application details, email server configuration, and environment settings.
/// Application configuration settings.
///
/// Loads configuration from YAML files and environment variables.
#[derive(Debug, serde::Deserialize, Clone, Default)]
pub struct Settings {
/// Application-specific settings (name, version, host, port, etc.)
pub application: ApplicationSettings,
/// Debug mode flag
pub debug: bool,
/// Email server configuration for contact form
pub email: EmailSettings,
/// Frontend URL for CORS configuration
pub frontend_url: String,
/// Rate limiting configuration
#[serde(default)]
pub rate_limit: RateLimitSettings,
}
impl Settings {
/// Creates a new `Settings` instance by loading configuration from files and environment variables.
///
/// # Errors
///
/// Returns a `config::ConfigError` if:
/// - Configuration files cannot be read or parsed
/// - Required configuration values are missing
/// - Configuration values cannot be deserialized into the expected types
///
/// # Panics
///
/// Panics if:
/// - The current directory cannot be determined
/// - The `APP_ENVIRONMENT` variable contains an invalid value (not "dev", "development", "prod", or "production")
pub fn new() -> Result<Self, config::ConfigError> {
let base_path = std::env::current_dir().expect("Failed to determine the current directory");
let settings_directory = base_path.join("settings");
let environment: Environment = std::env::var("APP_ENVIRONMENT")
.unwrap_or_else(|_| "dev".into())
.try_into()
.expect("Failed to parse APP_ENVIRONMENT");
let environment_filename = format!("{environment}.yaml");
// Lower = takes precedence
let settings = config::Config::builder()
.add_source(config::File::from(settings_directory.join("base.yaml")))
.add_source(config::File::from(
settings_directory.join(environment_filename),
))
.add_source(
config::Environment::with_prefix("APP")
.prefix_separator("__")
.separator("__"),
)
.build()?;
settings.try_deserialize()
}
}
/// Application-specific configuration settings.
#[derive(Debug, serde::Deserialize, Clone, Default)]
pub struct ApplicationSettings {
/// Application name
pub name: String,
/// Application version
pub version: String,
/// Port to bind to
pub port: u16,
/// Host address to bind to
pub host: String,
/// Base URL of the application
pub base_url: String,
/// Protocol (http or https)
pub protocol: String,
}
/// Application environment.
#[derive(Debug, PartialEq, Eq, Default)]
pub enum Environment {
/// Development environment
#[default]
Development,
/// Production environment
Production,
}
impl std::fmt::Display for Environment {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let self_str = match self {
Self::Development => "development",
Self::Production => "production",
};
write!(f, "{self_str}")
}
}
impl TryFrom<String> for Environment {
type Error = String;
fn try_from(value: String) -> Result<Self, Self::Error> {
Self::try_from(value.as_str())
}
}
impl TryFrom<&str> for Environment {
type Error = String;
fn try_from(value: &str) -> Result<Self, Self::Error> {
match value.to_lowercase().as_str() {
"development" | "dev" => Ok(Self::Development),
"production" | "prod" => Ok(Self::Production),
other => Err(format!(
"{other} is not a supported environment. Use either `development` or `production`"
)),
}
}
}
/// Email server configuration for the contact form.
#[derive(serde::Deserialize, Clone, Default)]
pub struct EmailSettings {
/// SMTP server hostname
pub host: String,
/// SMTP server port
pub port: u16,
/// SMTP authentication username
pub user: String,
/// Email address to send from
pub from: String,
/// SMTP authentication password
pub password: String,
/// Email address to send contact form submissions to
pub recipient: String,
/// STARTTLS configuration
pub starttls: Starttls,
/// Whether to use implicit TLS (SMTPS)
pub tls: bool,
}
impl EmailSettings {
/// Parses the sender email address into a `Mailbox` for use with lettre.
///
/// # Errors
///
/// Returns a `ContactError` if the email address in the `from` field cannot be parsed
/// into a valid mailbox. This can occur if:
/// - The email address format is invalid
/// - The email address contains invalid characters
/// - The email address structure is malformed
pub fn try_sender_into_mailbox(
&self,
) -> Result<lettre::message::Mailbox, crate::errors::ContactError> {
Ok(self.from.parse::<lettre::message::Mailbox>()?)
}
/// Parses the recipient email address into a `Mailbox` for use with lettre.
///
/// # Errors
///
/// Returns a `ContactError` if the email address in the `from` field cannot be parsed
/// into a valid mailbox. This can occur if:
/// - The email address format is invalid
/// - The email address contains invalid characters
/// - The email address structure is malformed
pub fn try_recpient_into_mailbox(
&self,
) -> Result<lettre::message::Mailbox, crate::errors::ContactError> {
Ok(self.recipient.parse::<lettre::message::Mailbox>()?)
}
}
impl std::fmt::Debug for EmailSettings {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("EmailSettings")
.field("host", &self.host)
.field("port", &self.port)
.field("user", &self.user)
.field("from", &self.from)
.field("password", &"[REDACTED]")
.field("recipient", &self.recipient)
.field("starttls", &self.starttls)
.field("tls", &self.tls)
.finish()
}
}
/// STARTTLS configuration for SMTP connections.
#[derive(Debug, PartialEq, Eq, Default, Clone)]
pub enum Starttls {
/// Never use STARTTLS (unencrypted connection)
#[default]
Never,
/// Use STARTTLS if available (opportunistic encryption)
Opportunistic,
/// Always use STARTTLS (required encryption)
Always,
}
impl TryFrom<&str> for Starttls {
type Error = String;
fn try_from(value: &str) -> Result<Self, Self::Error> {
match value.to_lowercase().as_str() {
"off" | "no" | "never" => Ok(Self::Never),
"opportunistic" => Ok(Self::Opportunistic),
"yes" | "always" => Ok(Self::Always),
other => Err(format!(
"{other} is not a supported option. Use either `yes`, `no`, or `opportunistic`"
)),
}
}
}
impl TryFrom<String> for Starttls {
type Error = String;
fn try_from(value: String) -> Result<Self, Self::Error> {
value.as_str().try_into()
}
}
impl From<bool> for Starttls {
fn from(value: bool) -> Self {
if value { Self::Always } else { Self::Never }
}
}
impl std::fmt::Display for Starttls {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let self_str = match self {
Self::Never => "never",
Self::Opportunistic => "opportunistic",
Self::Always => "always",
};
write!(f, "{self_str}")
}
}
impl<'de> serde::Deserialize<'de> for Starttls {
fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
where
D: serde::Deserializer<'de>,
{
struct StartlsVisitor;
impl serde::de::Visitor<'_> for StartlsVisitor {
type Value = Starttls;
fn expecting(&self, formatter: &mut std::fmt::Formatter) -> std::fmt::Result {
formatter.write_str("a string or boolean representing STARTTLS setting (e.g., 'yes', 'no', 'opportunistic', true, false)")
}
fn visit_str<E>(self, value: &str) -> Result<Starttls, E>
where
E: serde::de::Error,
{
Starttls::try_from(value).map_err(E::custom)
}
fn visit_string<E>(self, value: String) -> Result<Starttls, E>
where
E: serde::de::Error,
{
Starttls::try_from(value.as_str()).map_err(E::custom)
}
fn visit_bool<E>(self, value: bool) -> Result<Starttls, E>
where
E: serde::de::Error,
{
Ok(Starttls::from(value))
}
}
deserializer.deserialize_any(StartlsVisitor)
}
}
/// Rate limiting configuration.
#[derive(Debug, serde::Deserialize, Clone)]
pub struct RateLimitSettings {
/// Whether rate limiting is enabled
#[serde(default = "default_rate_limit_enabled")]
pub enabled: bool,
/// Maximum number of requests allowed in the time window (burst size)
#[serde(default = "default_burst_size")]
pub burst_size: u32,
/// Time window in seconds for rate limiting
#[serde(default = "default_per_seconds")]
pub per_seconds: u64,
}
impl Default for RateLimitSettings {
fn default() -> Self {
Self {
enabled: default_rate_limit_enabled(),
burst_size: default_burst_size(),
per_seconds: default_per_seconds(),
}
}
}
const fn default_rate_limit_enabled() -> bool {
true
}
const fn default_burst_size() -> u32 {
100
}
const fn default_per_seconds() -> u64 {
60
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn environment_display_development() {
let env = Environment::Development;
assert_eq!(env.to_string(), "development");
}
#[test]
fn environment_display_production() {
let env = Environment::Production;
assert_eq!(env.to_string(), "production");
}
#[test]
fn environment_from_str_development() {
assert_eq!(
Environment::try_from("development").unwrap(),
Environment::Development
);
assert_eq!(
Environment::try_from("dev").unwrap(),
Environment::Development
);
assert_eq!(
Environment::try_from("Development").unwrap(),
Environment::Development
);
assert_eq!(
Environment::try_from("DEV").unwrap(),
Environment::Development
);
}
#[test]
fn environment_from_str_production() {
assert_eq!(
Environment::try_from("production").unwrap(),
Environment::Production
);
assert_eq!(
Environment::try_from("prod").unwrap(),
Environment::Production
);
assert_eq!(
Environment::try_from("Production").unwrap(),
Environment::Production
);
assert_eq!(
Environment::try_from("PROD").unwrap(),
Environment::Production
);
}
#[test]
fn environment_from_str_invalid() {
let result = Environment::try_from("invalid");
assert!(result.is_err());
assert!(result.unwrap_err().contains("not a supported environment"));
}
#[test]
fn environment_from_string_development() {
assert_eq!(
Environment::try_from("development".to_string()).unwrap(),
Environment::Development
);
}
#[test]
fn environment_from_string_production() {
assert_eq!(
Environment::try_from("production".to_string()).unwrap(),
Environment::Production
);
}
#[test]
fn environment_from_string_invalid() {
let result = Environment::try_from("invalid".to_string());
assert!(result.is_err());
}
#[test]
fn environment_default_is_development() {
let env = Environment::default();
assert_eq!(env, Environment::Development);
}
#[test]
fn startls_deserialize_from_string_never() {
let json = r#""never""#;
let result: Starttls = serde_json::from_str(json).unwrap();
assert_eq!(result, Starttls::Never);
let json = r#""no""#;
let result: Starttls = serde_json::from_str(json).unwrap();
assert_eq!(result, Starttls::Never);
let json = r#""off""#;
let result: Starttls = serde_json::from_str(json).unwrap();
assert_eq!(result, Starttls::Never);
}
#[test]
fn startls_deserialize_from_string_always() {
let json = r#""always""#;
let result: Starttls = serde_json::from_str(json).unwrap();
assert_eq!(result, Starttls::Always);
let json = r#""yes""#;
let result: Starttls = serde_json::from_str(json).unwrap();
assert_eq!(result, Starttls::Always);
}
#[test]
fn startls_deserialize_from_string_opportunistic() {
let json = r#""opportunistic""#;
let result: Starttls = serde_json::from_str(json).unwrap();
assert_eq!(result, Starttls::Opportunistic);
}
#[test]
fn startls_deserialize_from_bool() {
let json = "true";
let result: Starttls = serde_json::from_str(json).unwrap();
assert_eq!(result, Starttls::Always);
let json = "false";
let result: Starttls = serde_json::from_str(json).unwrap();
assert_eq!(result, Starttls::Never);
}
#[test]
fn startls_deserialize_from_string_invalid() {
let json = r#""invalid""#;
let result: Result<Starttls, _> = serde_json::from_str(json);
assert!(result.is_err());
}
#[test]
fn startls_default_is_never() {
let startls = Starttls::default();
assert_eq!(startls, Starttls::Never);
}
#[test]
fn startls_try_from_str_never() {
assert_eq!(Starttls::try_from("never").unwrap(), Starttls::Never);
assert_eq!(Starttls::try_from("no").unwrap(), Starttls::Never);
assert_eq!(Starttls::try_from("off").unwrap(), Starttls::Never);
assert_eq!(Starttls::try_from("NEVER").unwrap(), Starttls::Never);
assert_eq!(Starttls::try_from("No").unwrap(), Starttls::Never);
}
#[test]
fn startls_try_from_str_always() {
assert_eq!(Starttls::try_from("always").unwrap(), Starttls::Always);
assert_eq!(Starttls::try_from("yes").unwrap(), Starttls::Always);
assert_eq!(Starttls::try_from("ALWAYS").unwrap(), Starttls::Always);
assert_eq!(Starttls::try_from("Yes").unwrap(), Starttls::Always);
}
#[test]
fn startls_try_from_str_opportunistic() {
assert_eq!(
Starttls::try_from("opportunistic").unwrap(),
Starttls::Opportunistic
);
assert_eq!(
Starttls::try_from("OPPORTUNISTIC").unwrap(),
Starttls::Opportunistic
);
}
#[test]
fn startls_try_from_str_invalid() {
let result = Starttls::try_from("invalid");
assert!(result.is_err());
assert!(result.unwrap_err().contains("not a supported option"));
}
#[test]
fn startls_try_from_string_never() {
assert_eq!(
Starttls::try_from("never".to_string()).unwrap(),
Starttls::Never
);
}
#[test]
fn startls_try_from_string_always() {
assert_eq!(
Starttls::try_from("yes".to_string()).unwrap(),
Starttls::Always
);
}
#[test]
fn startls_try_from_string_opportunistic() {
assert_eq!(
Starttls::try_from("opportunistic".to_string()).unwrap(),
Starttls::Opportunistic
);
}
#[test]
fn startls_try_from_string_invalid() {
let result = Starttls::try_from("invalid".to_string());
assert!(result.is_err());
}
#[test]
fn startls_from_bool_true() {
assert_eq!(Starttls::from(true), Starttls::Always);
}
#[test]
fn startls_from_bool_false() {
assert_eq!(Starttls::from(false), Starttls::Never);
}
#[test]
fn startls_display_never() {
let startls = Starttls::Never;
assert_eq!(startls.to_string(), "never");
}
#[test]
fn startls_display_always() {
let startls = Starttls::Always;
assert_eq!(startls.to_string(), "always");
}
#[test]
fn startls_display_opportunistic() {
let startls = Starttls::Opportunistic;
assert_eq!(startls.to_string(), "opportunistic");
}
#[test]
fn rate_limit_settings_default() {
let settings = RateLimitSettings::default();
assert!(settings.enabled);
assert_eq!(settings.burst_size, 100);
assert_eq!(settings.per_seconds, 60);
}
#[test]
fn rate_limit_settings_deserialize_full() {
let json = r#"{"enabled": true, "burst_size": 50, "per_seconds": 30}"#;
let settings: RateLimitSettings = serde_json::from_str(json).unwrap();
assert!(settings.enabled);
assert_eq!(settings.burst_size, 50);
assert_eq!(settings.per_seconds, 30);
}
#[test]
fn rate_limit_settings_deserialize_partial() {
let json = r#"{"enabled": false}"#;
let settings: RateLimitSettings = serde_json::from_str(json).unwrap();
assert!(!settings.enabled);
assert_eq!(settings.burst_size, 100); // default
assert_eq!(settings.per_seconds, 60); // default
}
#[test]
fn rate_limit_settings_deserialize_empty() {
let json = "{}";
let settings: RateLimitSettings = serde_json::from_str(json).unwrap();
assert!(settings.enabled); // default
assert_eq!(settings.burst_size, 100); // default
assert_eq!(settings.per_seconds, 60); // default
}
#[test]
fn startls_deserialize_from_incompatible_type() {
// Test that deserialization from an array fails with expected error message
let json = "[1, 2, 3]";
let result: Result<Starttls, _> = serde_json::from_str(json);
assert!(result.is_err());
let error = result.unwrap_err().to_string();
// The error should mention what was expected
assert!(
error.contains("STARTTLS") || error.contains("string") || error.contains("boolean")
);
}
#[test]
fn startls_deserialize_from_number() {
// Test that deserialization from a number fails
let json = "42";
let result: Result<Starttls, _> = serde_json::from_str(json);
assert!(result.is_err());
}
#[test]
fn startls_deserialize_from_object() {
// Test that deserialization from an object fails
let json = r#"{"foo": "bar"}"#;
let result: Result<Starttls, _> = serde_json::from_str(json);
assert!(result.is_err());
}
#[test]
fn email_settings_debug_redacts_password() {
let settings = EmailSettings {
host: "smtp.example.com".to_string(),
port: 587,
user: "user@example.com".to_string(),
from: "noreply@example.com".to_string(),
password: "super_secret_password".to_string(),
recipient: "admin@example.com".to_string(),
starttls: Starttls::Always,
tls: false,
};
let debug_output = format!("{settings:?}");
// Password should be redacted
assert!(debug_output.contains("[REDACTED]"));
// Password should not appear in output
assert!(!debug_output.contains("super_secret_password"));
// Other fields should still be present
assert!(debug_output.contains("smtp.example.com"));
assert!(debug_output.contains("user@example.com"));
}
#[test]
fn email_settings_try_sender_into_mailbox_success() {
let settings = EmailSettings {
host: "smtp.example.com".to_string(),
port: 587,
user: "user@example.com".to_string(),
from: "sender@example.com".to_string(),
password: "password".to_string(),
recipient: "recipient@example.com".to_string(),
starttls: Starttls::Always,
tls: false,
};
let result = settings.try_sender_into_mailbox();
assert!(result.is_ok());
let mailbox = result.unwrap();
assert_eq!(mailbox.email.to_string(), "sender@example.com");
}
#[test]
fn email_settings_try_sender_into_mailbox_invalid() {
let settings = EmailSettings {
host: "smtp.example.com".to_string(),
port: 587,
user: "user@example.com".to_string(),
from: "invalid-email".to_string(),
password: "password".to_string(),
recipient: "recipient@example.com".to_string(),
starttls: Starttls::Always,
tls: false,
};
let result = settings.try_sender_into_mailbox();
assert!(result.is_err());
}
#[test]
fn email_settings_try_recipient_into_mailbox_success() {
let settings = EmailSettings {
host: "smtp.example.com".to_string(),
port: 587,
user: "user@example.com".to_string(),
from: "sender@example.com".to_string(),
password: "password".to_string(),
recipient: "recipient@example.com".to_string(),
starttls: Starttls::Always,
tls: false,
};
let result = settings.try_recpient_into_mailbox();
assert!(result.is_ok());
let mailbox = result.unwrap();
assert_eq!(mailbox.email.to_string(), "recipient@example.com");
}
#[test]
fn email_settings_try_recipient_into_mailbox_invalid() {
let settings = EmailSettings {
host: "smtp.example.com".to_string(),
port: 587,
user: "user@example.com".to_string(),
from: "sender@example.com".to_string(),
password: "password".to_string(),
recipient: "invalid-email".to_string(),
starttls: Starttls::Always,
tls: false,
};
let result = settings.try_recpient_into_mailbox();
assert!(result.is_err());
}
}