elektromail 0.1.1

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

/// 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,
        }
    }

    /// Build a runtime-auth store from this configuration.
    pub(crate) fn into_store(self) -> AuthStore {
        AuthStore::from_config(self)
    }

    /// 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()
    }
}

/// Shared interface for runtime user provisioning and authentication.
pub trait UserStore: Send + Sync {
    /// Validate credentials against configured users.
    fn authenticate(&self, user: &str, pass: &str) -> bool;
    /// Add or update a user with an optional email address.
    fn add_user(&self, user: &str, pass: &str, email: Option<&str>) -> bool;
    /// List configured users (sorted).
    fn list_users(&self) -> Vec<String>;
    /// Reset to the initial seed users and emails.
    fn reset_to_seed(&self);
    /// Return whether authentication is disabled.
    fn auth_disabled(&self) -> bool;
    /// Return the number of configured users.
    fn user_count(&self) -> usize;
    /// Return the email address for a user, if known.
    fn email_for(&self, user: &str) -> Option<String>;
}

/// Shared, thread-safe user store handle.
pub type SharedUserStore = Arc<dyn UserStore>;

#[derive(Clone, Debug)]
pub struct AuthStore {
    inner: Arc<RwLock<AuthState>>,
}

#[derive(Debug)]
struct AuthState {
    users: HashMap<String, String>,
    seed_users: HashMap<String, String>,
    emails: HashMap<String, String>,
    seed_emails: HashMap<String, String>,
    allow_default: bool,
    disabled: bool,
}

impl AuthStore {
    fn from_config(config: AuthConfig) -> Self {
        let state = AuthState {
            seed_users: config.users.clone(),
            users: config.users,
            emails: HashMap::new(),
            seed_emails: HashMap::new(),
            allow_default: config.allow_default,
            disabled: config.disabled,
        };
        Self {
            inner: Arc::new(RwLock::new(state)),
        }
    }

    pub fn authenticate(&self, user: &str, pass: &str) -> bool {
        let state = self.inner.read().expect("auth store lock poisoned");
        if state.disabled {
            return true;
        }
        if !state.users.is_empty() {
            return state.users.get(user).is_some_and(|stored| stored == pass);
        }
        state.allow_default && user == "user" && pass == "pass"
    }

    pub fn add_user(&self, user: &str, pass: &str, email: Option<&str>) -> bool {
        if user.is_empty() || pass.is_empty() {
            return false;
        }
        let mut state = self.inner.write().expect("auth store lock poisoned");
        state.users.insert(user.to_string(), pass.to_string());
        if let Some(email) = email {
            if !email.trim().is_empty() {
                state.emails.insert(user.to_string(), email.to_string());
            }
        }
        true
    }

    pub fn list_users(&self) -> Vec<String> {
        let mut users = {
            let state = self.inner.read().expect("auth store lock poisoned");
            let mut users: Vec<String> = state.users.keys().cloned().collect();
            if users.is_empty() && state.allow_default {
                users.push("user".to_string());
            }
            drop(state);
            users
        };
        users.sort();
        users
    }

    pub fn reset_to_seed(&self) {
        let mut state = self.inner.write().expect("auth store lock poisoned");
        let seed_users = state.seed_users.clone();
        let seed_emails = state.seed_emails.clone();
        state.users = seed_users;
        state.emails = seed_emails;
    }

    pub fn auth_disabled(&self) -> bool {
        let state = self.inner.read().expect("auth store lock poisoned");
        state.disabled
    }

    pub fn user_count(&self) -> usize {
        let state = self.inner.read().expect("auth store lock poisoned");
        if state.users.is_empty() && state.allow_default {
            1
        } else {
            state.users.len()
        }
    }

    pub fn email_for(&self, user: &str) -> Option<String> {
        let state = self.inner.read().expect("auth store lock poisoned");
        state.emails.get(user).cloned()
    }
}

impl UserStore for AuthStore {
    fn authenticate(&self, user: &str, pass: &str) -> bool {
        AuthStore::authenticate(self, user, pass)
    }

    fn add_user(&self, user: &str, pass: &str, email: Option<&str>) -> bool {
        AuthStore::add_user(self, user, pass, email)
    }

    fn list_users(&self) -> Vec<String> {
        AuthStore::list_users(self)
    }

    fn reset_to_seed(&self) {
        AuthStore::reset_to_seed(self);
    }

    fn auth_disabled(&self) -> bool {
        AuthStore::auth_disabled(self)
    }

    fn user_count(&self) -> usize {
        AuthStore::user_count(self)
    }

    fn email_for(&self, user: &str) -> Option<String> {
        AuthStore::email_for(self, user)
    }
}

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 auth_store_add_and_reset() {
        let store = AuthConfig::from_users("seed:seedpass").into_store();
        assert!(store.authenticate("seed", "seedpass"));
        assert!(!store.authenticate("new", "newpass"));

        store.add_user("new", "newpass", Some("new@example.com"));
        assert!(store.authenticate("new", "newpass"));
        assert_eq!(store.email_for("new"), Some("new@example.com".to_string()));
        assert!(store.list_users().contains(&"new".to_string()));

        store.reset_to_seed();
        assert!(!store.authenticate("new", "newpass"));
        assert!(store.authenticate("seed", "seedpass"));
    }

    #[test]
    fn auth_disabled_accepts_any_credentials() {
        let store = AuthConfig::disabled().into_store();
        assert!(store.authenticate("any", "thing"));
        assert!(store.auth_disabled());
    }

    #[test]
    fn auth_store_is_thread_safe() {
        let store = AuthConfig::from_users("alpha:pass").into_store();
        store.add_user("beta", "pass", None);

        let mut handles = Vec::new();
        for _ in 0..8 {
            let store = store.clone();
            handles.push(std::thread::spawn(move || {
                for _ in 0..1000 {
                    assert!(store.authenticate("alpha", "pass"));
                    assert!(store.authenticate("beta", "pass"));
                }
            }));
        }

        for handle in handles {
            handle.join().expect("auth thread failed");
        }
    }
    #[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"));
    }
}