ch-config 0.1.1

Typed configuration loading with validation and secret-file support
Documentation
use std::fmt;
use std::str::FromStr;

use super::network::parse_network_url;
use super::validation::{optional_text, redacted};
use super::{ConfigError, Result, variables};

/// Email transport selection.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum EmailTransport {
    /// Select a transport at the application edge.
    Auto,
    /// Write messages to application logs.
    #[default]
    Log,
    /// Deliver through the MailerSend API.
    MailerSend,
    /// Deliver through SMTP.
    Smtp,
}

impl FromStr for EmailTransport {
    type Err = ();

    fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
        match value.to_ascii_lowercase().as_str() {
            "auto" => Ok(Self::Auto),
            "log" => Ok(Self::Log),
            "mailersend" => Ok(Self::MailerSend),
            "smtp" => Ok(Self::Smtp),
            _ => Err(()),
        }
    }
}

impl EmailTransport {
    /// Stable configuration spelling.
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::Auto => "auto",
            Self::Log => "log",
            Self::MailerSend => "mailersend",
            Self::Smtp => "smtp",
        }
    }
}

/// SMTP transport security mode.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum EmailSmtpSecurity {
    /// No transport encryption.
    #[default]
    None,
    /// Upgrade the connection with STARTTLS.
    StartTls,
    /// Use TLS from connection establishment.
    Tls,
}

impl FromStr for EmailSmtpSecurity {
    type Err = ();

    fn from_str(value: &str) -> std::result::Result<Self, Self::Err> {
        match value.to_ascii_lowercase().as_str() {
            "none" => Ok(Self::None),
            "starttls" => Ok(Self::StartTls),
            "tls" => Ok(Self::Tls),
            _ => Err(()),
        }
    }
}

impl EmailSmtpSecurity {
    /// Stable configuration spelling.
    pub const fn as_str(self) -> &'static str {
        match self {
            Self::None => "none",
            Self::StartTls => "starttls",
            Self::Tls => "tls",
        }
    }
}

/// Email delivery settings.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct EmailConfig {
    /// Selected transport.
    pub transport: EmailTransport,
    /// Sender address.
    pub from_email: String,
    /// Sender display name.
    pub from_name: String,
    /// SMTP-specific settings.
    pub smtp: EmailSmtpConfig,
    /// MailerSend-specific settings.
    pub mailersend: EmailMailerSendConfig,
}

/// SMTP connection and authentication settings.
#[derive(Clone, Default, PartialEq, Eq)]
pub struct EmailSmtpConfig {
    /// SMTP server hostname.
    pub host: String,
    /// SMTP server port.
    pub port: u16,
    /// Transport security mode.
    pub security: EmailSmtpSecurity,
    /// Optional authentication username.
    pub username: Option<String>,
    /// Optional authentication password.
    pub password: Option<String>,
}

impl fmt::Debug for EmailSmtpConfig {
    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
        formatter
            .debug_struct("EmailSmtpConfig")
            .field("host", &self.host)
            .field("port", &self.port)
            .field("security", &self.security)
            .field("username", &self.username.as_deref().map(redacted))
            .field("password", &self.password.as_deref().map(redacted))
            .finish()
    }
}

/// MailerSend API settings.
#[derive(Clone, Debug, Default, PartialEq, Eq)]
pub struct EmailMailerSendConfig {
    /// Email API endpoint.
    pub api_url: String,
}

impl EmailConfig {
    /// Creates MailerSend delivery defaults for an application-owned sender.
    pub fn mailersend(from_email: impl Into<String>, from_name: impl Into<String>) -> Self {
        Self {
            transport: EmailTransport::MailerSend,
            from_email: from_email.into(),
            from_name: from_name.into(),
            smtp: EmailSmtpConfig::default(),
            mailersend: EmailMailerSendConfig {
                api_url: "https://api.mailersend.com/v1/email".to_owned(),
            },
        }
    }

    /// Creates SMTP delivery defaults for an application-owned sender.
    pub fn smtp(
        from_email: impl Into<String>,
        from_name: impl Into<String>,
        host: impl Into<String>,
        port: u16,
        security: EmailSmtpSecurity,
    ) -> Self {
        Self {
            transport: EmailTransport::Smtp,
            from_email: from_email.into(),
            from_name: from_name.into(),
            smtp: EmailSmtpConfig {
                host: host.into(),
                port,
                security,
                username: None,
                password: None,
            },
            mailersend: EmailMailerSendConfig {
                api_url: "https://api.mailersend.com/v1/email".to_owned(),
            },
        }
    }

    pub(crate) fn apply_environment(
        &mut self,
        value: &mut impl FnMut(&'static str) -> Result<Option<String>>,
    ) -> Result<()> {
        if let Some(transport) = variables::parsed(
            "EMAIL_TRANSPORT",
            value("EMAIL_TRANSPORT")?,
            "must be one of `auto`, `log`, `mailersend`, or `smtp`",
        )? {
            self.transport = transport;
        }
        if let Some(from_email) = value("EMAIL_FROM_EMAIL")? {
            self.from_email = from_email;
        }
        if let Some(from_name) = value("EMAIL_FROM_NAME")? {
            self.from_name = from_name;
        }
        if let Some(host) = value("SMTP_HOST")? {
            self.smtp.host = host;
        }
        if let Some(port) = variables::parsed(
            "SMTP_PORT",
            value("SMTP_PORT")?,
            "must be an integer TCP port",
        )? {
            self.smtp.port = port;
        }
        if let Some(security) = variables::parsed(
            "SMTP_SECURITY",
            value("SMTP_SECURITY")?,
            "must be one of `none`, `starttls`, or `tls`",
        )? {
            self.smtp.security = security;
        }
        if let Some(username) = value("SMTP_USERNAME")? {
            self.smtp.username = Some(username);
        }
        if let Some(password) = value("SMTP_PASSWORD")? {
            self.smtp.password = Some(password);
        }
        if let Some(api_url) = value("MAILERSEND_API_URL")? {
            self.mailersend.api_url = api_url;
        }
        Ok(())
    }

    /// Reports whether email delivery settings are present.
    pub fn is_configured(&self) -> bool {
        self.transport != EmailTransport::Auto
            || !self.from_email.is_empty()
            || !self.from_name.is_empty()
            || self.smtp != EmailSmtpConfig::default()
            || self.mailersend != EmailMailerSendConfig::default()
    }

    /// Validates the selected delivery transport.
    pub fn validate(&self) -> Result<()> {
        if !self.from_email.is_empty() {
            let address = self.from_email.trim();
            if address.is_empty()
                || email_address::EmailAddress::parse_with_options(address, Default::default())
                    .is_err()
            {
                return Err(ConfigError::validation(
                    "email.from_email",
                    "must be a valid email address",
                ));
            }
        }

        optional_text(
            "email.mailersend.api_url",
            (!self.mailersend.api_url.is_empty()).then_some(self.mailersend.api_url.as_str()),
        )?;
        if !self.mailersend.api_url.is_empty() {
            parse_network_url(
                "email.mailersend.api_url",
                &self.mailersend.api_url,
                &["https"],
                false,
                "must be an HTTPS URL with a host and no embedded credentials",
            )?;
        }

        if !self.is_configured() || self.transport == EmailTransport::Log {
            return Ok(());
        }
        if self.from_email.trim().is_empty() {
            return Err(ConfigError::validation(
                "email.from_email",
                "must not be empty for message delivery",
            ));
        }
        if self.from_name.trim().is_empty() {
            return Err(ConfigError::validation(
                "email.from_name",
                "must not be empty for message delivery",
            ));
        }
        match self.transport {
            EmailTransport::Auto | EmailTransport::Log => Ok(()),
            EmailTransport::MailerSend => {
                if self.mailersend.api_url.trim().is_empty() {
                    Err(ConfigError::validation(
                        "email.mailersend.api_url",
                        "must not be empty for MailerSend delivery",
                    ))
                } else {
                    Ok(())
                }
            }
            EmailTransport::Smtp => {
                if self.smtp.host.trim().is_empty() {
                    return Err(ConfigError::validation(
                        "email.smtp.host",
                        "must not be empty for SMTP delivery",
                    ));
                }
                if self.smtp.port == 0 {
                    return Err(ConfigError::validation(
                        "email.smtp.port",
                        "must be greater than zero",
                    ));
                }
                optional_text("email.smtp.username", self.smtp.username.as_deref())?;
                optional_text("email.smtp.password", self.smtp.password.as_deref())?;
                if self.smtp.username.is_some() != self.smtp.password.is_some() {
                    return Err(ConfigError::validation(
                        "email.smtp",
                        "username and password must be provided together",
                    ));
                }
                Ok(())
            }
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn mailersend_config(from_email: &str, api_url: &str) -> EmailConfig {
        EmailConfig {
            transport: EmailTransport::MailerSend,
            from_email: from_email.to_owned(),
            from_name: "Example".to_owned(),
            smtp: EmailSmtpConfig::default(),
            mailersend: EmailMailerSendConfig {
                api_url: api_url.to_owned(),
            },
        }
    }

    #[test]
    fn validates_sender_address_syntax() {
        for invalid in ["not-an-address", "missing-domain@", "a b@example.com"] {
            let error = mailersend_config(invalid, "https://api.example.invalid/v1/email")
                .validate()
                .expect_err("invalid sender must fail");
            assert!(error.to_string().contains("email.from_email"));
            assert!(!error.to_string().contains(invalid));
        }

        mailersend_config(
            "sender+tag@example.invalid",
            "https://api.example.invalid/v1/email",
        )
        .validate()
        .expect("valid sender should pass");
    }

    #[test]
    fn mailersend_requires_https_host_without_credentials() {
        for invalid in [
            "http://api.example.invalid/v1/email",
            "https:///v1/email",
            "https://user:secret@api.example.invalid/v1/email",
        ] {
            let error = mailersend_config("sender@example.invalid", invalid)
                .validate()
                .expect_err("invalid endpoint must fail");
            assert!(error.to_string().contains("email.mailersend.api_url"));
            assert!(!error.to_string().contains(invalid));
        }
    }

    #[test]
    fn inactive_configured_mailersend_endpoint_is_still_validated() {
        let mut config = EmailConfig::default();
        config.mailersend.api_url = "http://api.example.invalid".to_owned();
        assert!(config.validate().is_err());
    }
}