tuimail 0.1.0

Email in your terminal: a vim-style IMAP, POP3 and SMTP client
mod account;
mod app;
mod compose;
mod composer;
mod config;
mod demo;
mod icons;
mod imap;
#[cfg(test)]
mod live;
mod mail;
mod net;
mod notify;
mod pop3;
mod secrets;
mod settings;
mod setup;
mod smtp;
mod store;
mod text;
mod theme;
mod tools;
mod ui;
mod utf7;
mod worker;

use std::collections::HashMap;
use std::io::{Write, stdout};

use anyhow::{Context, Result, bail};
use crossterm::event::{DisableBracketedPaste, EnableBracketedPaste};
use crossterm::execute;

use crate::account::{Auth, PasswordFrom};

fn main() -> Result<()> {
    // Before any thread exists: PATH keeps only its absolute folders.
    // tuimail names every program it starts by its full path, but code it
    // uses starts some by name (crossterm's `tput`), and so does xdg-open:
    // an empty or relative entry would let one planted in the folder
    // tuimail was started from run.
    #[cfg(unix)]
    match tools::absolute_path_entries(std::env::var_os("PATH")) {
        // SAFETY: first thing in main, before the runtime or any thread.
        Some(path) => unsafe { std::env::set_var("PATH", path) },
        // SAFETY: as above.
        None => unsafe { std::env::remove_var("PATH") },
    }
    tokio::runtime::Builder::new_multi_thread()
        .enable_all()
        .build()?
        .block_on(run())
}

async fn run() -> Result<()> {
    config::load_dotenv();
    let mut args = std::env::args().skip(1);
    let mut check = false;
    match args.next().as_deref() {
        None => {}
        Some("--check") => check = true,
        Some("--demo") => return demo::run().await,
        Some("-h" | "--help") => return print(&help()?),
        Some("-V" | "--version") => {
            return print(&format!("tuimail {}\n", env!("CARGO_PKG_VERSION")));
        }
        Some(other) => bail!(
            "unknown argument {:?}; see tuimail --help",
            text::clean(other)
        ),
    }

    // Everything that can fail does so before the terminal is taken over,
    // so errors print as normal text.
    let config = config::Config::load()?;
    let settings_path = settings::path(&config.data_dir);
    let (settings, unknown_settings) = settings::Settings::load_checked(&settings_path)?;
    let (accounts, warnings) = account::load(&account::path(&config.data_dir))?;
    let keychain = secrets::init_keychain();
    if check {
        return print_check(&config, &accounts, &warnings, keychain);
    }
    let store = store::Store::open(&config.data_dir.join("tuimail.db"))?;

    // Password commands run now, with the terminal, so one that needs to
    // be unlocked (gpg, a password manager) can ask.
    let mut passwords = HashMap::new();
    for account in &accounts {
        if account.password == PasswordFrom::Command || account.auth == Auth::Oauth2 {
            match secrets::run_command(&account.password_command, true).await {
                Ok(secret) if account.auth != Auth::Oauth2 => {
                    passwords.insert(account.id(), secret);
                }
                Ok(_) => {}
                Err(e) => eprintln!("{}: {e:#}", account.shown_name()),
            }
        }
    }

    need_terminal()?;
    let (tx, rx) = tokio::sync::mpsc::unbounded_channel();
    let mut terminal = ratatui::init();
    panic_hook();
    // A pasted path arrives as one event instead of keystrokes.
    execute!(stdout(), EnableBracketedPaste)?;
    let mut app = app::App::with_passwords(
        store,
        settings,
        accounts,
        passwords,
        keychain,
        Some(config.data_dir.clone()),
        tx,
    );
    let mut notes = warnings;
    if !unknown_settings.is_empty() {
        notes.push(format!(
            "settings.toml: tuimail doesn't know {}, so it's ignored",
            unknown_settings.join(", ")
        ));
    }
    if let Some(note) = notes.first() {
        app.error(note.clone());
    }
    let result = app.run(&mut terminal, rx).await;
    drop(app);
    let _ = execute!(stdout(), DisableBracketedPaste);
    ratatui::restore();
    result
}

/// A panic anywhere but in parsing or laying out a message (caught there)
/// ends the app: the terminal is put back, then the message is printed
/// without control characters, since it can quote a message.
pub fn panic_hook() {
    std::panic::set_hook(Box::new(|info| {
        if mail::panic_is_contained() {
            return;
        }
        let _ = execute!(stdout(), DisableBracketedPaste, crossterm::cursor::Show);
        ratatui::restore();
        eprintln!("tuimail crashed: {}", text::clean(&info.to_string()));
        std::process::exit(101);
    }));
}

/// `tuimail --check`: where things are and what tuimail found.
fn print_check(
    config: &config::Config,
    accounts: &[account::Account],
    warnings: &[String],
    keychain: bool,
) -> Result<()> {
    let mut out = format!(
        "tuimail {}\ndata folder  {}\nkeychain     {}\n",
        env!("CARGO_PKG_VERSION"),
        config::shown(&config.data_dir),
        if keychain {
            "the system's"
        } else {
            "none: passwords are asked for at each start"
        }
    );
    if accounts.is_empty() {
        out.push_str("accounts     none yet: start tuimail to add one\n");
    }
    for a in accounts {
        let protocol = match a.protocol {
            account::Protocol::Imap => "IMAP",
            account::Protocol::Pop3 => "POP3",
        };
        let password = match (a.auth, a.password) {
            (Auth::Oauth2, _) => "OAuth2 token from password_command",
            (_, PasswordFrom::Keychain) => "keychain",
            (_, PasswordFrom::Ask) => "asked",
            (_, PasswordFrom::Command) => "password_command",
        };
        out.push_str(&format!(
            "account      {} <{}>\n             {protocol} {}:{} ({:?}), sending {}:{} ({:?}), password: {password}\n",
            a.shown_name(),
            text::one_line(&a.email, 80),
            text::one_line(&a.host, 80),
            a.port(),
            a.security,
            text::one_line(&a.smtp_host, 80),
            a.smtp_port(),
            a.smtp_security,
        ));
    }
    for warning in warnings {
        out.push_str(&format!("warning      {warning}\n"));
    }
    print(&out)
}

/// A plain error, before the screen is taken over, when tuimail isn't run
/// in a terminal (piped, or from a script): there'd be nothing to draw on.
pub fn need_terminal() -> Result<()> {
    use std::io::IsTerminal;
    if !std::io::stdin().is_terminal() || !stdout().is_terminal() {
        bail!("tuimail needs a terminal to run in (tuimail --help says more)");
    }
    Ok(())
}

/// Writes to stdout. A reader that stops early (`tuimail --help | head -1`)
/// is fine, where `println!` would panic on the closed pipe.
fn print(text: &str) -> Result<()> {
    match stdout().lock().write_all(text.as_bytes()) {
        Err(e) if e.kind() == std::io::ErrorKind::BrokenPipe => Ok(()),
        result => Ok(result.context("cannot write")?),
    }
}

fn help() -> Result<String> {
    Ok(format!(
        "tuimail {version}
Email in your terminal: IMAP and POP3 to read, SMTP to send, with arrow
keys or vim keys.

Usage: tuimail [-h | --help] [-V | --version] [--check] [--demo]

Inside the app, ? lists the keys and q quits. The first start asks for
an account: your address and password, the servers filled in for the
usual providers. --check says what tuimail found; --demo shows made-up
mail, connecting nowhere.

Passwords are kept in the system's keychain (or asked for each start,
or printed by a command of yours: see the README). Accounts, settings
and a cache of your mail are kept only on this computer, in:
  {data}

Environment:
  TM_DATA_DIR   keep them somewhere else (a full path)
  TM_ICONS      which icons: auto, nerd (Nerd Font) or plain
                (also `icons` in settings.toml)
",
        version = env!("CARGO_PKG_VERSION"),
        data = config::shown(&config::data_dir()?),
    ))
}