ecr-cli 0.2.0

The ecr command line: setup, diagnostics, server lifecycle and pairing
mod doctor;
mod help;
mod qr;
mod serve;
mod token;

use clap::{Parser, Subcommand};
use ecr_server::auth::TokenStore;
use std::net::SocketAddr;
use std::path::PathBuf;

#[derive(Parser)]
#[command(
    name = "ecr",
    about = "a mail client",
    // Two channels are published from this repository — the newest release and
    // whatever `main` is at. They can carry the same Cargo version, so a build
    // that cannot name its own commit cannot answer "which one am I running".
    version = option_env!("ECR_BUILD_VERSION").unwrap_or(env!("CARGO_PKG_VERSION")),
    disable_help_subcommand = true,
    after_help = "Run `ecr help` for worked examples."
)]
struct Cli {
    #[arg(long, global = true, help = "path to the device token store")]
    tokens: Option<PathBuf>,

    #[command(subcommand)]
    command: Option<Command>,
}

#[derive(Subcommand)]
enum Command {
    #[command(about = "set up the mail configuration, adopting whatever already exists")]
    Init {
        #[arg(long, help = "regenerate the files ecr owns, backing up what is there")]
        force: bool,
    },

    #[command(about = "report on the mail setup and name the fix for anything broken")]
    Doctor {
        #[arg(long)]
        json: bool,
    },

    #[command(about = "run the server")]
    Serve {
        #[arg(long, default_value = "127.0.0.1:8383")]
        bind: SocketAddr,

        #[arg(long, help = "refuse every write: no tagging, syncing or sending")]
        read_only: bool,

        #[arg(long, help = "do not watch the maildir for delivered mail")]
        no_watch: bool,

        #[arg(
            long,
            help = "restrict browser origins; repeatable. Default allows any, because auth is a bearer token and no cookies are used"
        )]
        allowed_origin: Vec<String>,

        #[arg(
            long,
            help = "directory holding the built web client; found automatically if omitted"
        )]
        web_dir: Option<PathBuf>,
    },

    #[command(about = "stop the server running in the background")]
    Stop,

    #[command(about = "report whether the server is running")]
    Status,

    #[command(about = "restart the server running in the background")]
    Restart,

    #[command(about = "show the server's log")]
    Logs {
        #[arg(short, long, help = "keep printing as the log grows")]
        follow: bool,

        #[arg(short = 'n', long, default_value_t = 200, help = "lines to show")]
        lines: usize,
    },

    #[command(about = "open the web client in a browser, starting a server if none is running")]
    Web,

    #[command(about = "print a QR code that pairs a phone with this server")]
    Qr {
        #[arg(default_value = "phone", help = "name recorded for the device")]
        name: String,
    },

    #[command(about = "authorize and refresh OAuth tokens for Gmail and Outlook")]
    Oauth {
        #[command(subcommand)]
        command: OauthCommand,
    },

    #[command(about = "issue, list and revoke device tokens")]
    Token {
        #[command(subcommand)]
        command: TokenCommand,
    },

    #[command(about = "worked examples, organised by what you are trying to do")]
    Help {
        #[arg(help = "one of: start, phone, autostart, accounts, trouble")]
        topic: Option<String>,
    },

    // Hidden because they are for whoever is packaging ecr, not for whoever is
    // reading mail. Every packaging path — the Nix derivation, the release
    // tarball — generates its man page and completions by running the binary it
    // just built, so the two can never describe a different command tree than
    // the one being shipped.
    #[command(about = "print a shell completion script", hide = true)]
    Completions {
        #[arg(help = "bash, elvish, fish, powershell or zsh")]
        shell: clap_complete::Shell,
    },

    #[command(about = "print this manual page in roff", hide = true)]
    Man,
}

#[derive(Subcommand)]
enum OauthCommand {
    #[command(about = "run the browser flow and store a refresh token")]
    Authorize { profile: String },
    #[command(about = "print a valid access token, refreshing it if needed")]
    Token { profile: String },
    #[command(about = "print the base64 XOAUTH2 string IMAP and SMTP want")]
    Xoauth2 { profile: String },
    #[command(about = "report a profile's provider, address and token expiry")]
    Status { profile: String },
}

#[derive(Subcommand)]
enum TokenCommand {
    New {
        name: String,
        #[arg(long, help = "also print a QR code for pairing a phone")]
        qr: bool,
    },
    List,
    Revoke {
        name: String,
    },
}

#[tokio::main]
async fn main() {
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| "ecr_server=info,ecr_store=info,tower_http=warn".into()),
        )
        .with_writer(std::io::stderr)
        .init();

    if let Err(err) = dispatch().await {
        // The dev shell sets RUST_BACKTRACE=1, which would otherwise attach a
        // stack trace to every operational error. What went wrong is the
        // useful part; the frames are not.
        eprintln!("\nerror: {err}");
        for cause in err.chain().skip(1) {
            eprintln!("  caused by: {cause}");
        }
        std::process::exit(1);
    }
}

async fn dispatch() -> anyhow::Result<()> {
    let cli = Cli::parse();
    let token_path = cli.tokens.unwrap_or_else(TokenStore::default_path);

    let Some(command) = cli.command else {
        return not_yet(
            "the desktop client is not wired up yet",
            "Run `ecr web` to open the client in a browser.",
        );
    };

    match command {
        Command::Doctor { json } => doctor::run(json).await,

        Command::Serve {
            bind,
            read_only,
            no_watch,
            allowed_origin,
            web_dir,
        } => {
            serve::run(serve::Options {
                bind,
                read_only,
                no_watch,
                allowed_origins: allowed_origin,
                web_dir,
                token_path,
            })
            .await
        }

        Command::Token { command } => match command {
            TokenCommand::New { name, qr } => token::new(&token_path, &name, qr),
            TokenCommand::List => token::list(&token_path),
            TokenCommand::Revoke { name } => token::revoke(&token_path, &name),
        },

        Command::Help { topic } => help::run(topic.as_deref()),

        Command::Completions { shell } => {
            let mut command = <Cli as clap::CommandFactory>::command();
            clap_complete::generate(shell, &mut command, "ecr", &mut std::io::stdout());
            Ok(())
        }

        Command::Man => {
            clap_mangen::Man::new(<Cli as clap::CommandFactory>::command())
                .render(&mut std::io::stdout())?;
            Ok(())
        }

        Command::Init { .. } => not_yet(
            "`ecr init` is not implemented yet",
            "Set up notmuch, mbsync and msmtp by hand, then run `ecr doctor`.",
        ),
        Command::Stop | Command::Status | Command::Restart => not_yet(
            "the server does not run in the background yet",
            "Run `ecr serve` in a terminal; ctrl-c stops it.",
        ),
        Command::Logs { .. } => not_yet(
            "`ecr logs` is not implemented yet",
            "`ecr serve` logs to stderr; RUST_LOG controls the level.",
        ),
        Command::Web => not_yet(
            "`ecr web` is not implemented yet",
            "Run `ecr serve` and open the address it prints.",
        ),
        Command::Qr { .. } => not_yet(
            "`ecr qr` is not implemented yet",
            "`ecr token new <name> --qr` prints a token and a QR code.",
        ),
        Command::Oauth { .. } => not_yet(
            "`ecr oauth` is not implemented yet",
            "Use `oauthman` directly; ecr reads the profiles it writes.",
        ),
    }
}

fn not_yet(what: &str, meanwhile: &str) -> anyhow::Result<()> {
    anyhow::bail!("{what}.\n\n  {meanwhile}\n")
}