http-smtp-rele 0.13.0

Minimal, secure HTTP-to-SMTP submission relay
Documentation
//! Configuration validation logic.
//!
//! Separated from type definitions to keep `config.rs` focused on the schema.

use super::*;

impl Default for RateLimitConfig {
    fn default() -> Self {
        Self {
            global_per_min:    default_global_per_min(),
            per_ip_per_min:    default_per_ip_per_min(),
            per_key_per_min:   default_per_key_per_min(),
            global_burst:      default_global_burst(),
            per_ip_burst:      default_per_ip_burst(),
            per_key_burst:     default_per_key_burst(),
            ip_table_size:     default_ip_table_size(),
            burst_size:        0,
        }
    }
}

impl Default for LoggingConfig {
    fn default() -> Self {
        Self {
            format:         default_log_format(),
            level:          default_log_level(),
            mask_recipient: false,
        }
    }
}

// ---------------------------------------------------------------------------
// Load and validate
// ---------------------------------------------------------------------------

pub fn load_from_str(toml_str: &str) -> Result<AppConfig, ConfigError> {
    let config: AppConfig = toml::from_str(toml_str)?;
    validate_config(&config)?;
    Ok(config)
}

pub fn load(path: &Path) -> Result<AppConfig, ConfigError> {
    let text = std::fs::read_to_string(path)?;
    let config: AppConfig = toml::from_str(&text)?;
    validate_config(&config)?;
    Ok(config)
}

pub fn validate_config(config: &AppConfig) -> Result<(), ConfigError> {
    // bind_address
    config
        .server
        .bind_address
        .parse::<std::net::SocketAddr>()
        .map_err(|_| ConfigError::InvalidBindAddress)?;

    // default_from
    config
        .mail
        .default_from
        .parse::<Address>()
        .map_err(|_| ConfigError::InvalidDefaultFrom)?;

    // API keys
    if config.security.require_auth && config.security.api_keys.is_empty() {
        return Err(ConfigError::NoApiKeys);
    }
    if config.security.require_auth
        && !config.security.api_keys.iter().any(|k| k.enabled)
    {
        return Err(ConfigError::NoEnabledApiKeys);
    }

    // CIDRs — validate both lists
    for cidr in config.security.trusted_source_cidrs.iter()
        .chain(config.security.allowed_source_cidrs.iter())
    {
        cidr.parse::<ipnet::IpNet>()
            .map_err(|_| ConfigError::InvalidCidr(cidr.clone()))?;
    }

    // SMTP port
    if config.smtp.port == 0 {
        return Err(ConfigError::InvalidSmtpPort);
    }

    // SMTP TLS mode
    match config.smtp.tls.as_str() {
        "none" | "starttls" | "tls" => {}
        other => return Err(ConfigError::Validation(
            format!("smtp.tls must be \"none\", \"starttls\", or \"tls\"; got \"{other}\"")
        )),
    }

    // SMTP AUTH: both user and password must be set or both absent
    match (&config.smtp.auth_user, &config.smtp.auth_password) {
        (Some(_), None) | (None, Some(_)) => {
            return Err(ConfigError::Validation(
                "smtp.auth_user and smtp.auth_password must both be set or both absent".into(),
            ));
        }
        _ => {}
    }

    // TLS: cert and key must both be set or both absent (RFC 712)
    match (&config.server.tls_cert, &config.server.tls_key) {
        (Some(_), None) | (None, Some(_)) => {
            return Err(ConfigError::Validation(
                "server.tls_cert and server.tls_key must both be set or both be absent".into()
            ));
        }
        (Some(_), Some(_)) => {
            #[cfg(not(feature = "tls"))]
            return Err(ConfigError::Validation(
                "server.tls_cert/tls_key is configured but TLS is not available in this build.                  Rebuild with: cargo build --features tls".into()
            ));
        }
        (None, None) => {}
    }

    // Status store validation (RFC 087, 088, 722)
    if config.status.store == "sqlite" {
        if config.status.db_path.is_none() {
            return Err(ConfigError::Validation(
                "status.db_path is required when status.store = \"sqlite\"".into()
            ));
        }
        #[cfg(not(feature = "sqlite"))]
        return Err(ConfigError::Validation(
            "status.store = \"sqlite\" is not available in this build.              Rebuild with: cargo build --features sqlite".into()
        ));
    } else if config.status.store == "redis" {
        if config.status.redis_url.is_none() {
            return Err(ConfigError::Validation(
                "status.redis_url is required when status.store = \"redis\"".into()
            ));
        }
        #[cfg(not(feature = "redis"))]
        return Err(ConfigError::Validation(
            "status.store = \"redis\" is not available in this build.              Rebuild with: cargo build --features redis".into()
        ));
    } else if !matches!(config.status.store.as_str(), "memory") {
        return Err(ConfigError::Validation(
            format!("status.store must be \"memory\", \"sqlite\", or \"redis\"; got \"{}\""
                , config.status.store)
        ));
    }

    // Pipe mode: auth credentials are not applicable
    if config.smtp.mode == "pipe"
        && (config.smtp.auth_user.is_some() || config.smtp.auth_password.is_some())
    {
        return Err(ConfigError::Validation(
            r#"smtp.auth_user/auth_password are not applicable when smtp.mode = "pipe""#.into(),
        ));
    }

    // Rate limits
    if config.rate_limit.global_per_min == 0 || config.rate_limit.per_ip_per_min == 0 {
        return Err(ConfigError::InvalidRateLimit);
    }

    // Log level
    let valid_levels = ["trace", "debug", "info", "warn", "error"];
    if !valid_levels.contains(&config.logging.level.as_str()) {
        return Err(ConfigError::InvalidLogLevel);
    }

    // Log format
    let valid_formats = ["text", "json"];
    if !valid_formats.contains(&config.logging.format.as_str()) {
        return Err(ConfigError::InvalidLogFormat);
    }

    Ok(())
}

// ---------------------------------------------------------------------------
// Tests
// ---------------------------------------------------------------------------

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

    fn minimal_config_str() -> String {
        r#"
[server]
bind_address = "127.0.0.1:8080"

[security]
require_auth = false

[rate_limit]

[mail]
default_from = "noreply@example.com"

[smtp]

[logging]
"#
        .into()
    }

    #[test]
    fn valid_config_parses() {
        let config: AppConfig = toml::from_str(&minimal_config_str()).unwrap();
        assert!(validate_config(&config).is_ok());
    }

    #[test]
    fn invalid_bind_address() {
        let text = minimal_config_str().replace("127.0.0.1:8080", "notanaddress");
        let config: AppConfig = toml::from_str(&text).unwrap();
        assert!(matches!(validate_config(&config), Err(ConfigError::InvalidBindAddress)));
    }

    #[test]
    fn invalid_default_from() {
        let text = minimal_config_str().replace("noreply@example.com", "notanemail");
        let config: AppConfig = toml::from_str(&text).unwrap();
        assert!(matches!(validate_config(&config), Err(ConfigError::InvalidDefaultFrom)));
    }

    #[test]
    fn require_auth_no_keys() {
        let text = minimal_config_str().replace("require_auth = false", "require_auth = true");
        let config: AppConfig = toml::from_str(&text).unwrap();
        assert!(matches!(validate_config(&config), Err(ConfigError::NoApiKeys)));
    }

    #[test]
    fn secret_string_is_redacted_in_debug() {
        let s = SecretString::new("very-secret");
        assert!(!format!("{:?}", s).contains("very-secret"));
        assert!(!format!("{}", s).contains("very-secret"));
        assert_eq!(s.expose(), "very-secret");
    }

    #[test]
    fn defaults_are_sensible() {
        let config: AppConfig = toml::from_str(&minimal_config_str()).unwrap();
        assert_eq!(config.server.max_request_body_bytes, 1_048_576);
        assert_eq!(config.smtp.port, 25);
        assert_eq!(config.rate_limit.global_per_min, 60);
        assert_eq!(config.logging.format, "text");
    }
}