elektromail 0.1.0

A minimal, Rust-based IMAP + SMTP mail server for local development and testing
Documentation
use std::collections::HashMap;

/// Authentication configuration loaded from environment.
#[derive(Clone, Debug)]
pub struct AuthConfig {
    users: HashMap<String, String>,
    allow_default: bool,
    disabled: bool,
}

impl Default for AuthConfig {
    fn default() -> Self {
        Self {
            users: HashMap::new(),
            allow_default: true,
            disabled: false,
        }
    }
}

impl AuthConfig {
    /// Load users from `ELEKTROMAIL_USERS`, or default to `user/pass`.
    pub fn from_env() -> Self {
        if auth_disabled_from_env() {
            return Self::disabled();
        }
        match std::env::var("ELEKTROMAIL_USERS") {
            Ok(value) => Self::from_env_value(&value),
            Err(_) => Self::default(),
        }
    }

    pub(crate) fn from_env_value(value: &str) -> Self {
        let users = parse_users(value);
        let allow_default = users.is_empty();
        Self {
            users,
            allow_default,
            disabled: false,
        }
    }

    /// Build auth config from explicit user:pass entries.
    pub fn from_users(value: &str) -> Self {
        Self::from_env_value(value)
    }

    /// Allow any credentials to authenticate.
    pub fn disabled() -> Self {
        Self {
            users: HashMap::new(),
            allow_default: true,
            disabled: true,
        }
    }

    /// Validate credentials against configured users.
    pub fn authenticate(&self, user: &str, pass: &str) -> bool {
        if self.disabled {
            return true;
        }
        if !self.users.is_empty() {
            return self.users.get(user).is_some_and(|stored| stored == pass);
        }
        self.allow_default && user == "user" && pass == "pass"
    }

    /// Return the number of configured users.
    pub fn user_count(&self) -> usize {
        self.users.len()
    }
}

fn parse_users(value: &str) -> HashMap<String, String> {
    let mut users = HashMap::new();
    for entry in value.split(',') {
        let entry = entry.trim();
        if entry.is_empty() {
            continue;
        }
        let mut parts = entry.split(':').map(str::trim).filter(|p| !p.is_empty());
        let user = parts.next();
        let second = parts.next();
        let third = parts.next();
        let (user, pass) = match (user, second, third) {
            (Some(user), Some(pass), None) => (user, pass),
            (Some(user), Some(_email), Some(pass)) => (user, pass),
            _ => continue,
        };
        if !user.is_empty() && !pass.is_empty() {
            users.insert(user.to_string(), pass.to_string());
        }
    }
    users
}

fn auth_disabled_from_env() -> bool {
    match std::env::var("ELEKTROMAIL_AUTH_DISABLED") {
        Ok(value) => matches!(value.as_str(), "1" | "true" | "TRUE" | "yes" | "YES"),
        Err(_) => false,
    }
}

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

    #[test]
    fn env_value_parses_single_user() {
        let config = AuthConfig::from_env_value("demo:demo");
        assert!(config.authenticate("demo", "demo"));
        assert!(!config.authenticate("user", "pass"));
    }

    #[test]
    fn env_value_parses_multiple_users() {
        let config = AuthConfig::from_env_value("demo:demopass,test:testpass");
        assert!(config.authenticate("demo", "demopass"));
        assert!(config.authenticate("test", "testpass"));
        assert!(!config.authenticate("demo", "wrong"));
    }

    #[test]
    fn env_value_parses_three_part_entries() {
        let config = AuthConfig::from_env_value("demo:demo@localhost:demopass");
        assert!(config.authenticate("demo", "demopass"));
    }

    #[test]
    fn empty_env_value_falls_back_to_default() {
        let config = AuthConfig::from_env_value("");
        assert!(config.authenticate("user", "pass"));
    }
}