ecr-cli 0.2.1

The ecr command line: setup, diagnostics, server lifecycle and pairing
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
mod doctor;
mod help;
mod init;
mod oauth;
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>,

        #[arg(
            long,
            help = "fail instead of offering to set up a missing mail configuration"
        )]
        no_init: bool,
    },

    #[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 = "create a profile and authorize it in one step")]
    Setup {
        #[command(flatten)]
        profile: ProfileArgs,
        #[command(flatten)]
        flow: FlowArgs,
    },
    #[command(about = "create a profile without authorizing it")]
    Init {
        #[command(flatten)]
        profile: ProfileArgs,
    },
    #[command(about = "run the browser flow and store a refresh token")]
    Authorize {
        profile: String,
        #[command(flatten)]
        flow: FlowArgs,
    },
    #[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 },
    #[command(name = "client-id", about = "print a built-in OAuth client id")]
    ClientId {
        #[command(flatten)]
        client: ClientArgs,
    },
    #[command(name = "client-secret", about = "print a built-in OAuth client secret")]
    ClientSecret {
        #[command(flatten)]
        client: ClientArgs,
    },
}

#[derive(clap::Args)]
struct ProfileArgs {
    #[arg(help = "name for the profile; mbsync and msmtp refer to it by this")]
    profile: String,

    // No default. Both providers are plausible for any address, and guessing
    // wrong is not discovered until a browser flow has already been walked
    // through and the resulting token fails against the real server.
    #[arg(
        long,
        required = true,
        help = "gmail for Google accounts, microsoft for Outlook and Microsoft 365"
    )]
    provider: String,

    #[arg(long, required = true, help = "the address this profile authenticates")]
    email: String,

    #[arg(
        long,
        help = "built-in client preset to borrow; defaults to thunderbird"
    )]
    client: Option<String>,

    #[arg(long, help = "your own OAuth client id, instead of a preset")]
    client_id: Option<String>,

    #[arg(long, help = "your own OAuth client secret")]
    client_secret: Option<String>,

    #[arg(long, help = "Microsoft tenant; defaults to common")]
    tenant: Option<String>,

    #[arg(long, help = "override the requested scopes; repeatable")]
    scope: Vec<String>,

    #[arg(
        long,
        help = "loopback port for the browser callback; defaults to a free one"
    )]
    redirect_port: Option<u16>,

    #[arg(long, help = "replace an existing profile, discarding its tokens")]
    force: bool,
}

#[derive(clap::Args)]
struct FlowArgs {
    #[arg(
        long,
        default_value = "auto",
        help = "auto, authcode or device. auto takes the device flow where the provider offers one"
    )]
    flow: String,

    #[arg(
        long,
        default_value_t = 300,
        help = "seconds to wait for authorization"
    )]
    timeout: u64,

    #[arg(long, help = "print the URL instead of opening a browser")]
    no_open: bool,
}

#[derive(clap::Args)]
struct ClientArgs {
    #[arg(long, required = true, help = "gmail or microsoft")]
    provider: String,

    #[arg(long, help = "built-in client preset; defaults to thunderbird")]
    client: Option<String>,
}

#[derive(Subcommand)]
enum TokenCommand {
    New {
        name: String,
        #[arg(long, help = "also print a QR code for pairing a phone")]
        qr: bool,
        #[arg(
            long,
            help = "the address the phone should reach this server on, put in the QR alongside the token"
        )]
        url: Option<String>,
    },
    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,
            no_init,
        } => {
            serve::run(serve::Options {
                bind,
                read_only,
                no_watch,
                allowed_origins: allowed_origin,
                web_dir,
                token_path,
                no_init,
            })
            .await
        }

        Command::Token { command } => match command {
            TokenCommand::New { name, qr, url } => {
                token::new(&token_path, &name, qr, url.as_deref())
            }
            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 { force } => init::run(force).await,
        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 { command } => match command {
            OauthCommand::Setup { profile, flow } => {
                oauth::setup(profile.into(), flow.try_into()?).await
            }
            OauthCommand::Init { profile } => oauth::init(profile.into()).await,
            OauthCommand::Authorize { profile, flow } => {
                oauth::authorize(&profile, flow.try_into()?).await
            }
            OauthCommand::Token { profile } => oauth::token(&profile).await,
            OauthCommand::Xoauth2 { profile } => oauth::xoauth2(&profile).await,
            OauthCommand::Status { profile } => oauth::status(&profile),
            OauthCommand::ClientId { client } => {
                oauth::client_id(&client.provider, client.client.as_deref())
            }
            OauthCommand::ClientSecret { client } => {
                oauth::client_secret(&client.provider, client.client.as_deref())
            }
        },
    }
}

impl From<ProfileArgs> for oauth::Init {
    fn from(args: ProfileArgs) -> Self {
        oauth::Init {
            profile: args.profile,
            provider: args.provider,
            email: args.email,
            client: args.client,
            client_id: args.client_id,
            client_secret: args.client_secret,
            tenant: args.tenant,
            scope: args.scope,
            redirect_port: args.redirect_port,
            force: args.force,
        }
    }
}

impl TryFrom<FlowArgs> for oauth::Authorize {
    type Error = anyhow::Error;

    fn try_from(args: FlowArgs) -> anyhow::Result<Self> {
        Ok(oauth::Authorize {
            flow: match args.flow.as_str() {
                "auto" => ecr_store::oauth::Flow::Auto,
                "authcode" => ecr_store::oauth::Flow::AuthCode,
                "device" => ecr_store::oauth::Flow::Device,
                other => anyhow::bail!("unknown flow {other:?}; use auto, authcode or device"),
            },
            timeout: args.timeout,
            no_open: args.no_open,
        })
    }
}

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