use std::{env, fmt, net::SocketAddr, time::Duration}; pub use crate::error::ConfigError; use crate::ENV_PREFIX; use tracing; const DEFAULT_LISTEN_ADDR: &str = "127.0.0.1:50051"; #[derive(Debug, Clone, PartialEq, Eq)] pub struct AppConfig { pub smtp: SmtpConfig, pub queue_capacity: Option, pub listen_addr: SocketAddr, } #[derive(Clone, PartialEq, Eq)] pub struct SmtpConfig { pub host: String, pub port: u16, pub username: Option, pub password: Option, pub from_email: Option, pub from_name: Option, pub reply_to: Option, pub tls: SmtpTls, pub timeout: Duration, pub helo_name: Option, pub allow_request_from: bool, } impl fmt::Debug for SmtpConfig { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { f.debug_struct("SmtpConfig") .field("host", &self.host) .field("port", &self.port) .field("username", &self.username) .field("password", &self.password.as_ref().map(|_| "***")) .field("from_email", &self.from_email) .field("from_name", &self.from_name) .field("reply_to", &self.reply_to) .field("tls", &self.tls) .field("timeout", &self.timeout) .field("helo_name", &self.helo_name) .field("allow_request_from", &self.allow_request_from) .finish() } } #[derive(Debug, Default, Clone, Copy, PartialEq, Eq)] pub enum SmtpTls { None, #[default] StartTls, Tls, } impl AppConfig { pub fn from_etcd( host: String, port: u16, username: String, password: String, from_email: String, from_name: String, reply_to: String, tls: String, timeout_secs: u64, helo_name: String, allow_request_from: bool, queue_capacity: Option, listen_addr_str: &str, ) -> Result { let tls = match tls.trim().to_ascii_lowercase().as_str() { "none" | "false" | "0" => SmtpTls::None, "starttls" | "start_tls" | "start-tls" => SmtpTls::StartTls, "tls" | "ssl" | "smtps" => SmtpTls::Tls, _ => SmtpTls::StartTls, }; validate_port("PORT", port)?; Ok(Self { smtp: SmtpConfig { host, port, username: if username.is_empty() { None } else { Some(username) }, password: if password.is_empty() { None } else { Some(password) }, from_email: if from_email.is_empty() { None } else { Some(from_email) }, from_name: if from_name.is_empty() { None } else { Some(from_name) }, reply_to: if reply_to.is_empty() { None } else { Some(reply_to) }, tls, timeout: std::time::Duration::from_secs(timeout_secs), helo_name: if helo_name.is_empty() { None } else { Some(helo_name) }, allow_request_from, }, queue_capacity, listen_addr: listen_addr_str .parse::() .map_err(|e: std::net::AddrParseError| ConfigError::InvalidEnv { name: "LISTEN_ADDR", reason: e.to_string() })?, }) } pub fn from_env() -> Result { let _ = dotenvy::dotenv(); let queue_capacity: Option = optional("QUEUE_CAPACITY")? .map(|v| { v.parse::() .map_err(|e| invalid("QUEUE_CAPACITY", e.to_string())) }) .transpose()?; let listen_addr = parse_or( "LISTEN_ADDR", DEFAULT_LISTEN_ADDR.parse().expect("valid default address"), )?; Ok(Self { smtp: SmtpConfig::from_env()?, queue_capacity, listen_addr, }) } } impl SmtpConfig { pub fn from_env() -> Result { let _ = dotenvy::dotenv(); let host = required("HOST")?; let port = parse_or("PORT", 587)?; let from_email = optional("FROM_EMAIL")?; let reply_to = optional("REPLY_TO")?; let timeout_secs = parse_or("TIMEOUT_SECS", 30)?; let allow_request_from = parse_bool("ALLOW_REQUEST_FROM", false)?; validate_port("PORT", port)?; validate_email_if_present("FROM_EMAIL", from_email.as_deref())?; validate_email_if_present("REPLY_TO", reply_to.as_deref())?; let tls = parse_tls("TLS")?; if matches!(tls, SmtpTls::None) { tracing::warn!( "SMTP TLS is disabled — credentials and email content will be sent in plaintext" ); } Ok(Self { host, port, username: optional("USERNAME")?, password: optional("PASSWORD")?, from_email, from_name: optional("FROM_NAME")?, reply_to, tls, timeout: Duration::from_secs(timeout_secs), helo_name: optional("HELO_NAME")?, allow_request_from, }) } pub fn has_credentials(&self) -> bool { self.username.as_deref().is_some_and(|v| !v.is_empty()) && self.password.as_deref().is_some_and(|v| !v.is_empty()) } } impl fmt::Display for SmtpTls { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::None => f.write_str("none"), Self::StartTls => f.write_str("starttls"), Self::Tls => f.write_str("tls"), } } } fn required(name: &'static str) -> Result { match optional(name)? { Some(value) => Ok(value), None => Err(ConfigError::MissingEnv { name }), } } fn optional(name: &'static str) -> Result, ConfigError> { match env::var(env_name(name)) { Ok(value) if value.trim().is_empty() => Ok(None), Ok(value) => Ok(Some(value)), Err(env::VarError::NotPresent) => Ok(None), Err(env::VarError::NotUnicode(_)) => Err(invalid(name, "value is not valid UTF-8")), } } fn parse_or(name: &'static str, default: T) -> Result where T: std::str::FromStr, T::Err: fmt::Display, { optional(name)? .map(|value| { value .parse::() .map_err(|err| invalid(name, err.to_string())) }) .unwrap_or(Ok(default)) } fn parse_tls(name: &'static str) -> Result { let Some(value) = optional(name)? else { return Ok(SmtpTls::default()); }; match value.trim().to_ascii_lowercase().as_str() { "none" | "false" | "0" => Ok(SmtpTls::None), "starttls" | "start_tls" | "start-tls" => Ok(SmtpTls::StartTls), "tls" | "ssl" | "smtps" => Ok(SmtpTls::Tls), _ => Err(invalid(name, "expected one of: none, starttls, tls")), } } fn parse_bool(name: &'static str, default: bool) -> Result { let Some(value) = optional(name)? else { return Ok(default); }; match value.trim().to_ascii_lowercase().as_str() { "true" | "1" | "yes" | "y" | "on" => Ok(true), "false" | "0" | "no" | "n" | "off" => Ok(false), _ => Err(invalid(name, "expected a boolean value")), } } fn validate_port(name: &'static str, port: u16) -> Result<(), ConfigError> { if port == 0 { return Err(invalid(name, "must be between 1 and 65535")); } Ok(()) } fn validate_email_if_present(name: &'static str, value: Option<&str>) -> Result<(), ConfigError> { if let Some(value) = value && !(value.contains('@') && value.split('@').all(|part| !part.is_empty())) { return Err(invalid(name, "must be a valid email address")); } Ok(()) } fn env_name(name: &str) -> String { format!("{ENV_PREFIX}{name}") } fn invalid(name: &'static str, reason: impl Into) -> ConfigError { ConfigError::InvalidEnv { name, reason: reason.into(), } }