elma-tui 0.1.0

A modern terminal-based email client
mod app;
mod backend;
mod model;
mod ui;
mod viewer;

use crate::app::{AccountDescriptor, App};
use crate::backend::{
    MailBackend,
    gmail::GmailBackend,
    jmap::{JmapAuth, JmapBackend, JmapConfig},
    mock::MockBackend,
};
use anyhow::{Context, Result, anyhow};
use crossterm::{
    event::{self, Event},
    execute,
    terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use ratatui::{Terminal, prelude::CrosstermBackend};
use serde::Deserialize;
use std::io::{self, Stdout};
use std::{fs, path::PathBuf, sync::Arc, time::Duration};

const TICK_RATE: Duration = Duration::from_millis(100);

fn main() -> Result<()> {
    let args = std::env::args().skip(1);
    let mut demo_mode = false;

    for arg in args {
        match arg.as_str() {
            "-D" | "--demo" => demo_mode = true,
            "-h" | "--help" => {
                print_usage();
                return Ok(());
            }
            _ => {
                eprintln!("Unknown argument: {arg}");
                print_usage();
                return Ok(());
            }
        }
    }

    let accounts = load_accounts(demo_mode)?;

    let mut app = App::new(accounts).context("failed to initialize application state")?;
    run(&mut app).context("failed while running application loop")
}

fn run(app: &mut App) -> Result<()> {
    let mut terminal = init_terminal().context("failed to set up terminal")?;
    let result = loop {
        app.poll_backend_events();
        terminal
            .draw(|frame| ui::render(frame, app))
            .context("failed to render frame")?;

        if app.should_quit() {
            break Ok(());
        }

        if event::poll(TICK_RATE).context("failed to poll for events")? {
            match event::read().context("failed to read event")? {
                Event::Key(key) => app.handle_key(key).context("failed to handle key event")?,
                Event::Resize(_, _) => app.on_resize(),
                Event::Mouse(_) => {}
                Event::FocusGained | Event::FocusLost | Event::Paste(_) => {}
            }
        }
    };

    restore_terminal(terminal)?;
    result
}

fn init_terminal() -> Result<Terminal<CrosstermBackend<Stdout>>> {
    enable_raw_mode().context("failed to enable raw mode")?;
    let mut stdout = io::stdout();
    execute!(stdout, EnterAlternateScreen).context("failed to enter alternate screen")?;
    let backend = CrosstermBackend::new(stdout);
    Terminal::new(backend).context("failed to create terminal instance")
}

fn restore_terminal(mut terminal: Terminal<CrosstermBackend<Stdout>>) -> Result<()> {
    disable_raw_mode().context("failed to disable raw mode")?;
    execute!(terminal.backend_mut(), LeaveAlternateScreen)
        .context("failed to leave alternate screen")?;
    terminal.show_cursor().context("failed to show cursor")
}

fn print_usage() {
    println!("elma-rs - Ratatui-based mail client demo");
    println!();
    println!("USAGE:");
    println!("    elma-rs [OPTIONS]");
    println!();
    println!("OPTIONS:");
    println!("    -D, --demo    Run with the built-in mock backend (default)");
    println!("    -h, --help    Show this help message");
}

fn load_accounts(demo_mode: bool) -> Result<Vec<AccountDescriptor>> {
    if demo_mode {
        return Ok(vec![AccountDescriptor::new(
            "Demo",
            Arc::new(MockBackend::demo()),
        )]);
    }

    match load_accounts_from_config()? {
        Some(accounts) if !accounts.is_empty() => Ok(accounts),
        Some(_) => {
            eprintln!(
                "No accounts configured; falling back to demo backend (use --demo to hide this message)."
            );
            Ok(vec![AccountDescriptor::new(
                "Demo",
                Arc::new(MockBackend::demo()),
            )])
        }
        None => {
            eprintln!(
                "No configuration file found; using demo backend (use --demo to hide this message)."
            );
            Ok(vec![AccountDescriptor::new(
                "Demo",
                Arc::new(MockBackend::demo()),
            )])
        }
    }
}

fn load_accounts_from_config() -> Result<Option<Vec<AccountDescriptor>>> {
    let path = match config_path() {
        Some(path) => path,
        None => return Ok(None),
    };

    if !path.exists() {
        return Ok(None);
    }

    let raw = fs::read_to_string(&path)
        .with_context(|| format!("unable to read configuration file {}", path.display()))?;
    let config: Config = toml::from_str(&raw)
        .with_context(|| format!("unable to parse configuration file {}", path.display()))?;

    if let Some(entries) = config.accounts {
        let mut accounts = Vec::new();
        for (idx, entry) in entries.into_iter().enumerate() {
            accounts.push(build_account_from_config(entry, idx)?);
        }
        return Ok(Some(accounts));
    }

    Ok(Some(Vec::new()))
}

fn build_account_from_config(mut config: AccountConfig, index: usize) -> Result<AccountDescriptor> {
    let backend_name = config.r#type.to_ascii_lowercase();
    match backend_name.as_str() {
        "gmail" => {
            let username = config
                .email
                .ok_or_else(|| anyhow!("accounts[{index}].username missing for Gmail backend"))?;
            let password = config
                .password
                .ok_or_else(|| anyhow!("accounts[{index}].password missing for Gmail backend"))?;
            let backend = GmailBackend::new(&username, password)
                .with_context(|| format!("failed to initialize Gmail backend for {username}"))?;
            let name = config.name.unwrap_or(username);
            Ok(AccountDescriptor::new(name, Arc::new(backend)))
        }
        "demo" => {
            let name = config.name.unwrap_or("Demo".to_string());
            let backend: Arc<dyn MailBackend> = Arc::new(MockBackend::demo());
            Ok(AccountDescriptor::new(name, backend))
        }
        "jmap" => {
            let username = config
                .username
                .take()
                .or_else(|| config.email.clone())
                .ok_or_else(|| anyhow!("accounts[{index}].username missing for JMAP backend"))?;
            let auth = if let Some(token) = config.token.take() {
                JmapAuth::Bearer { token }
            } else {
                let password = config.password.take().ok_or_else(|| {
                    anyhow!("accounts[{index}].password or token missing for JMAP backend")
                })?;
                JmapAuth::Basic {
                    username: username.clone(),
                    password,
                }
            };
            let mut base_url = config
                .url
                .take()
                .unwrap_or_else(|| "https://api.fastmail.com/jmap/session".to_string());
            normalize_fastmail_url(&mut base_url);
            let mut trusted_hosts = config.redirect_hosts.take().unwrap_or_default();
            if let Some(host) = url_host(&base_url) {
                if !trusted_hosts
                    .iter()
                    .any(|existing| existing.eq_ignore_ascii_case(host))
                {
                    trusted_hosts.push(host.to_string());
                }
                extend_fastmail_hosts(host, &mut trusted_hosts);
            }
            let backend = JmapBackend::new(JmapConfig {
                base_url,
                auth,
                trusted_hosts,
            })
            .with_context(|| format!("failed to initialize JMAP backend for {username}"))?;
            let name = config.name.unwrap_or(username);
            Ok(AccountDescriptor::new(name, Arc::new(backend)))
        }
        other => Err(anyhow!("accounts[{index}]: unsupported backend '{other}'")),
    }
}

fn config_path() -> Option<PathBuf> {
    std::env::var_os("HOME").map(|home| PathBuf::from(home).join(".elmarc"))
}

#[derive(Debug, Deserialize)]
struct Config {
    #[serde(alias = "accounts")]
    accounts: Option<Vec<AccountConfig>>,
}

#[derive(Debug, Deserialize)]
struct AccountConfig {
    name: Option<String>,
    r#type: String,
    email: Option<String>,
    password: Option<String>,
    username: Option<String>,
    token: Option<String>,
    url: Option<String>,
    #[serde(alias = "redirect_hosts")]
    redirect_hosts: Option<Vec<String>>,
}

fn url_host(url: &str) -> Option<&str> {
    let without_scheme = url
        .strip_prefix("https://")
        .or_else(|| url.strip_prefix("http://"))
        .unwrap_or(url);
    let host = without_scheme.split('/').next()?.trim();
    if host.is_empty() { None } else { Some(host) }
}

fn extend_fastmail_hosts(host: &str, list: &mut Vec<String>) {
    if host.ends_with("fastmail.com") {
        for candidate in [
            "fastmail.com",
            "www.fastmail.com",
            "api.fastmail.com",
            "jmap.fastmail.com",
        ] {
            if !list
                .iter()
                .any(|existing| existing.eq_ignore_ascii_case(candidate))
            {
                list.push(candidate.to_string());
            }
        }
    }
}

fn normalize_fastmail_url(url: &mut String) {
    if url.eq_ignore_ascii_case("https://jmap.fastmail.com")
        || url.eq_ignore_ascii_case("https://jmap.fastmail.com/")
        || url.eq_ignore_ascii_case("https://jmap.fastmail.com/.well-known/jmap")
    {
        *url = "https://api.fastmail.com/jmap/session".to_string();
        return;
    }

    if url.eq_ignore_ascii_case("https://api.fastmail.com")
        || url.eq_ignore_ascii_case("https://api.fastmail.com/")
    {
        *url = "https://api.fastmail.com/jmap/session".to_string();
        return;
    }

    if url.ends_with('/') && !url.contains("/jmap/session") {
        while url.ends_with('/') {
            url.pop();
        }
    }

    if url.eq_ignore_ascii_case("api.fastmail.com") {
        *url = "https://api.fastmail.com/jmap/session".to_string();
    }
}