elektromail 0.1.1

A minimal, Rust-based IMAP + SMTP mail server for local development and testing
Documentation
//! # Elektromail
//!
//! A minimal, Rust-based IMAP + SMTP mail server for local development and testing.
//!
//! ## Overview
//!
//! Elektromail provides a lightweight mail server that can be embedded in tests or run
//! standalone for local development. It supports:
//!
//! - SMTP for receiving emails
//! - IMAP for reading emails
//! - STARTTLS for encrypted connections
//! - PLAIN authentication
//!
//! ## Quick Start
//!
//! ```rust,no_run
//! use elektromail::{Server, ServerConfig};
//!
//! #[tokio::main]
//! async fn main() -> std::io::Result<()> {
//!     let server = Server::start(ServerConfig::default()).await?;
//!     println!("SMTP listening on {}", server.smtp_addr());
//!     println!("IMAP listening on {}", server.imap_addr());
//!
//!     // Server runs until stopped
//!     server.stop().await
//! }
//! ```

use std::{
    io,
    net::{IpAddr, Ipv4Addr, SocketAddr},
    path::PathBuf,
    sync::{Arc, Mutex},
};

use tokio::{net::TcpListener, sync::broadcast, task::JoinHandle};

/// Event emitted when a mailbox changes (new message, etc.)
#[derive(Clone, Debug)]
pub struct MailboxEvent {
    /// The user whose mailbox changed
    pub user: String,
    /// The mailbox that changed
    pub mailbox: String,
    /// The new message count in the mailbox
    pub new_count: usize,
}

/// Sender for mailbox change notifications (used by SMTP/IMAP handlers)
pub type MailboxNotifier = broadcast::Sender<MailboxEvent>;

pub use crate::auth::{AuthConfig, SharedUserStore, UserStore};
pub use crate::delivery::{DeliveryPolicy, DsnAction, DsnPolicy, DsnRule, SharedDeliveryPolicy};
pub use crate::store::StorageBackend;
use crate::store::Store;

mod auth;
mod delivery;
mod http;
mod imap;
mod preload;
mod smtp;
mod sqlite_store;
mod store;
mod tls;

/// Configuration for the SMTP/IMAP server endpoints and auth source.
#[derive(Clone)]
pub struct ServerConfig {
    /// Address to bind the SMTP listener to.
    pub smtp_addr: SocketAddr,
    /// Address to bind the IMAP listener to.
    pub imap_addr: SocketAddr,
    /// Optional address to bind the HTTP control plane to.
    pub http_addr: Option<SocketAddr>,
    /// Optional bearer token required for HTTP control plane access.
    pub http_token: Option<String>,
    /// Optional filesystem preload directory for seed fixtures.
    pub preload_dir: Option<PathBuf>,
    /// Optional delivery policy override (defaults to env-based DSN policy).
    pub delivery_policy: Option<SharedDeliveryPolicy>,
    /// Optional auth configuration; defaults to `ELEKTROMAIL_USERS` when unset.
    pub auth: Option<AuthConfig>,
    /// Storage backend configuration.
    pub storage: StorageBackend,
}

impl std::fmt::Debug for ServerConfig {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        let delivery_policy = self.delivery_policy.is_some();
        f.debug_struct("ServerConfig")
            .field("smtp_addr", &self.smtp_addr)
            .field("imap_addr", &self.imap_addr)
            .field("http_addr", &self.http_addr)
            .field("http_token", &self.http_token)
            .field("preload_dir", &self.preload_dir)
            .field("delivery_policy", &delivery_policy)
            .field("auth", &self.auth)
            .field("storage", &self.storage)
            .finish()
    }
}

impl Default for ServerConfig {
    fn default() -> Self {
        let loopback = IpAddr::V4(Ipv4Addr::LOCALHOST);
        Self {
            smtp_addr: SocketAddr::new(loopback, 0),
            imap_addr: SocketAddr::new(loopback, 0),
            http_addr: None,
            http_token: None,
            preload_dir: None,
            delivery_policy: None,
            auth: None,
            storage: StorageBackend::InMemory,
        }
    }
}

/// Entry point for starting an in-process server.
pub struct Server;

impl Server {
    /// Start SMTP and IMAP listeners with the provided configuration.
    pub async fn start(config: ServerConfig) -> io::Result<RunningServer> {
        let mut store = Store::new(config.storage.clone())?;
        if let Some(preload_dir) = config.preload_dir.as_ref() {
            preload::load_from_dir(&mut store, preload_dir)?;
        } else {
            let _ = preload::load_from_env(&mut store)?;
        }
        let store = Arc::new(Mutex::new(store));
        let auth_store = config
            .auth
            .unwrap_or_else(AuthConfig::from_env)
            .into_store();
        let auth: SharedUserStore = std::sync::Arc::new(auth_store);
        let delivery_policy = config
            .delivery_policy
            .unwrap_or_else(|| std::sync::Arc::new(crate::delivery::DsnPolicy::from_env()));
        let smtp_listener = TcpListener::bind(config.smtp_addr).await?;
        let imap_listener = TcpListener::bind(config.imap_addr).await?;

        let smtp_addr = smtp_listener.local_addr()?;
        let imap_addr = imap_listener.local_addr()?;

        let (shutdown_tx, _) = broadcast::channel(4);
        // Create notification channel for IDLE push (capacity for bursts of notifications)
        let (mailbox_notifier, _) = broadcast::channel::<MailboxEvent>(64);

        let mut handles = Vec::new();

        let smtp_handle = tokio::spawn(smtp::run_smtp(
            smtp_listener,
            store.clone(),
            auth.clone(),
            delivery_policy.clone(),
            shutdown_tx.subscribe(),
            mailbox_notifier.clone(),
        ));
        handles.push(smtp_handle);

        let imap_handle = tokio::spawn(imap::run_imap(
            imap_listener,
            store.clone(),
            auth.clone(),
            shutdown_tx.subscribe(),
            mailbox_notifier,
        ));
        handles.push(imap_handle);

        // Start HTTP control plane if configured
        let http_addr = if let Some(addr) = config.http_addr {
            let http_listener = TcpListener::bind(addr).await?;
            let bound_addr = http_listener.local_addr()?;
            let http_handle = tokio::spawn(http::run_http(
                http_listener,
                store.clone(),
                auth.clone(),
                http::HttpMeta {
                    smtp_addr,
                    imap_addr,
                    http_addr: bound_addr,
                    storage: config.storage.clone(),
                    http_token: config.http_token.clone(),
                },
                shutdown_tx.subscribe(),
            ));
            handles.push(http_handle);
            Some(bound_addr)
        } else {
            None
        };

        Ok(RunningServer {
            smtp_addr,
            imap_addr,
            http_addr,
            shutdown_tx,
            handles,
        })
    }
}

/// Handle for a running server instance.
pub struct RunningServer {
    smtp_addr: SocketAddr,
    imap_addr: SocketAddr,
    http_addr: Option<SocketAddr>,
    shutdown_tx: broadcast::Sender<()>,
    handles: Vec<JoinHandle<()>>,
}

impl RunningServer {
    /// Return the bound SMTP address.
    pub fn smtp_addr(&self) -> SocketAddr {
        self.smtp_addr
    }

    /// Return the bound IMAP address.
    pub fn imap_addr(&self) -> SocketAddr {
        self.imap_addr
    }

    /// Return the bound HTTP address, if enabled.
    pub fn http_addr(&self) -> Option<SocketAddr> {
        self.http_addr
    }

    /// Shut down the server and wait for tasks to finish.
    pub async fn stop(self) -> io::Result<()> {
        let _ = self.shutdown_tx.send(());
        for handle in self.handles {
            let _ = handle.await;
        }
        Ok(())
    }
}