///| The `mails` command-line interface.
///|
let cli_version : String = "0.1.4"
///|
priv struct CliOptions {
mut command : String
mut config : String?
mut account : String?
mut write : Bool
mut new_key : Bool
mut once : Bool
mut no_verify : Bool
mut help : Bool
}
///|
fn default_options() -> CliOptions {
{
command: "",
config: None,
account: None,
write: false,
new_key: false,
once: false,
no_verify: false,
help: false,
}
}
///|
async fn print_line(text : String) -> Unit {
@stdio.stdout.write(text + "\n")
}
///|
async fn print_error(text : String) -> Unit {
@stdio.stderr.write("mails: " + text + "\n")
}
///|
fn cli_error(message : String) -> MailsError {
MailsError(message)
}
///|
fn parse_args(argv : Array[String]) -> CliOptions raise {
let opts = default_options()
let mut index = 1
while index < argv.length() {
let arg = argv[index]
if arg == "--config" || arg == "-c" {
index = index + 1
if index >= argv.length() {
raise cli_error("--config needs a path")
}
opts.config = Some(argv[index])
} else if arg == "--account" || arg == "-a" {
index = index + 1
if index >= argv.length() {
raise cli_error("--account needs a name")
}
opts.account = Some(argv[index])
} else if arg == "--write" {
opts.write = true
} else if arg == "--new-key" {
opts.new_key = true
} else if arg == "--once" {
opts.once = true
} else if arg == "--no-verify" {
opts.no_verify = true
} else if arg == "--help" || arg == "-h" {
opts.help = true
} else if arg == "--version" || arg == "-V" {
opts.command = "version"
} else if arg == "--verbose" || arg == "-v" {
set_log_level(Debug)
} else if arg == "--quiet" || arg == "-q" {
set_log_level(Warn)
} else if arg.has_prefix("-") && opts.command == "" {
raise cli_error("unknown option " + arg)
} else if opts.command == "" {
opts.command = arg
} else {
raise cli_error("unexpected argument '" + arg + "'")
}
index = index + 1
}
opts
}
///|
async fn print_help() -> Unit {
let lines : Array[String] = [
"mails " +
cli_version +
" - watch IMAP mailboxes and run commands on keyword matches",
"",
"usage: mails [options]",
"",
"commands:",
" watch follow mailboxes with IMAP IDLE",
" sync process existing matching mail once, then exit",
" check verify the config, key and IMAP logins",
" login sign in to an OAuth2 account (device code, no browser)",
" encrypt encrypt an account password with the key in .env",
" keygen create the encryption key in .env",
" rules list the configured keyword rules",
" env show the .env file with values masked",
" init write a starter mails.toml next to the binary",
" version print the version",
"",
"options:",
" -c, --config PATH configuration file (default: mails.toml next to the binary)",
" -a, --account NAME only this account",
" --no-verify for login: do not test the IMAP connection afterwards",
" --once for watch: process existing mail, then exit",
" --write for encrypt: update mails.toml in place",
" --new-key for encrypt: rotate the key before sealing",
" -v, --verbose debug logging",
" -q, --quiet warnings and errors only",
"",
"The config and .env live next to the mails binary, so a single directory",
"can be copied to another machine and keep working.",
]
for line in lines {
print_line(line)
}
}
///|
async fn config_and_env(opts : CliOptions) -> (Config, EnvFile, String) {
let path = match opts.config {
Some(value) => value
None =>
default_config_path() catch {
err => raise cli_error(error_message(err))
}
}
let config = load_config(path) catch {
err => raise cli_error(error_message(err))
}
let env = load_env_file(config.env_file) catch {
_ => ({ path: config.env_file, keys: [], values: [], } : EnvFile)
}
let key = match env.get(config.key_env) {
Some(value) => trim(value)
None =>
match @env.get_env_var(config.key_env) {
Some(value) => trim(value)
None => ""
}
}
(config, env, key)
}
///|
/// Pick exactly one account by `--account` or by being the only account.
fn pick_account(config : Config, name : String?) -> Account raise {
match name {
Some(wanted) => {
let accounts = selected_accounts(config, Some(wanted)) catch {
_ => raise cli_error("no account named '" + wanted + "'")
}
accounts[0]
}
None =>
if config.accounts.length() == 1 {
config.accounts[0]
} else {
let names = StringBuilder()
for account in config.accounts {
if names.to_string() != "" {
names.write_string(", ")
}
names.write_string(account.name)
}
raise cli_error(
"several accounts are configured (" +
names.to_string() +
"); pass --account NAME",
)
}
}
}
///|
/// Read one line from standard input (the password prompt).
async fn read_password(prompt : String) -> String {
@stdio.stderr.write(prompt)
let line = @stdio.stdin.read_until("\n") catch {
_ => raise cli_error("cannot read the password from standard input")
}
match line {
None => raise cli_error("no password was provided on standard input")
Some(text) => {
let mut value = text
if value.has_suffix("\r") {
value = value[0:value.length() - 1].to_owned()
}
if value == "" {
raise cli_error("the password is empty")
}
value
}
}
}
///|
async fn cmd_encrypt(opts : CliOptions) -> Unit {
let (config, env, existing_key) = config_and_env(opts)
let account = pick_account(config, opts.account)
let mut key = existing_key
if opts.new_key || key == "" {
key = generate_key_spec() catch {
err => raise cli_error(error_message(err))
}
set_env_var(config.env_file, config.key_env, key) catch {
err =>
raise cli_error(
"cannot write " + config.env_file + ": " + error_message(err),
)
}
log_info(
"wrote a new " +
config.key_env +
" to " +
config.env_file +
(if opts.new_key { " (rotated)" } else { "" }),
)
}
let password = read_password("IMAP password for " + account.name + ": ")
let envelope = seal_secret(key, password) catch {
err => raise cli_error(error_message(err))
}
if opts.write {
let text = @fs.read_file(config.path) catch {
err =>
raise cli_error(
"cannot read " + config.path + ": " + error_message(err),
)
}
let source = text.text() catch {
_ => raise cli_error(config.path + " is not valid UTF-8")
}
let updated = update_account_password(source, account.name, envelope) catch {
err => raise cli_error(error_message(err))
}
@fs.write_file(config.path, updated, create_mode=TruncateExisting) catch {
err =>
raise cli_error(
"cannot write " + config.path + ": " + error_message(err),
)
}
print_line(
"encrypted the password for '" + account.name + "' in " + config.path,
)
} else {
print_line("# password for account '" + account.name + "'")
print_line("password = \"" + envelope + "\"")
print_line("")
print_line(
"Paste the line above into [accounts." +
account.name +
"] of " +
config.path +
", or rerun with --write.",
)
}
if env.values.length() == 0 {
print_line("The key is stored in " + config.env_file + " (keep it secret).")
}
}
///|
async fn cmd_keygen(opts : CliOptions) -> Unit {
let (config, _env, existing) = config_and_env(opts)
let key = generate_key_spec() catch {
err => raise cli_error(error_message(err))
}
if existing != "" {
print_line(
"replacing the existing " +
config.key_env +
" in " +
config.env_file +
" (values encrypted with the old key can no longer be read)",
)
}
set_env_var(config.env_file, config.key_env, key) catch {
err =>
raise cli_error(
"cannot write " + config.env_file + ": " + error_message(err),
)
}
print_line("wrote " + config.key_env + " to " + config.env_file)
print_line("Run `mails encrypt --account NAME --write` for each account.")
}
///|
async fn cmd_env(opts : CliOptions) -> Unit {
let (config, env, _key) = config_and_env(opts)
print_line("# " + config.env_file)
print_line(render_env_for_display(env, false))
}
///|
async fn cmd_rules(opts : CliOptions) -> Unit {
let (config, _env, _key) = config_and_env(opts)
let accounts = selected_accounts(config, opts.account) catch {
err => raise cli_error(error_message(err))
}
let mut printed = 0
for account in accounts {
let rules = merge_rules(config.rules, account.rules)
for rule in rules {
printed = printed + 1
let fields = StringBuilder()
for field in rule.fields {
if fields.to_string() != "" {
fields.write_string(",")
}
fields.write_string(field.as_string())
}
let scope = if rule.accounts.length() == 0 {
"all accounts"
} else {
rule.accounts.join(", ")
}
print_line("[" + account.name + "] " + rule.name)
print_line(" keywords: " + rule.keywords.join(", "))
print_line(" fields: " + fields.to_string())
print_line(
" match: " + (if rule.require_all { "all" } else { "any" }),
)
print_line(" scope: " + scope)
print_line(
" command: " +
(if rule.argv.length() > 0 {
rule.argv.join(" ")
} else {
rule.shell_command
}),
)
}
}
if printed == 0 {
print_line("no rules are configured")
}
}
///|
async fn cmd_init(opts : CliOptions) -> Unit {
let path = match opts.config {
Some(value) => value
None =>
default_config_path() catch {
err => raise cli_error(error_message(err))
}
}
let exists = @fs.exists(path) catch { _ => false }
if exists {
raise cli_error(path + " already exists; move it away first")
}
@fs.write_file(path, sample_config(), create_mode=CreateNew) catch {
err => raise cli_error("cannot write " + path + ": " + error_message(err))
}
print_line("wrote " + path)
print_line("Next: edit the [accounts.example] table, then run")
print_line(" mails encrypt --account example --write")
}
///|
/// Sign in to an OAuth2 account with the device authorization grant.
///
/// Designed for a machine with no browser: it prints a URL and a short code,
/// the user approves them on any device, and this process polls until the
/// provider hands over a token.
async fn cmd_login(opts : CliOptions) -> Unit {
let (config, _env, key) = config_and_env(opts)
let accounts = selected_accounts(config, opts.account) catch {
err => raise cli_error(error_message(err))
}
if accounts.length() == 0 {
raise cli_error("no accounts selected")
}
for account in accounts {
if account.auth is PasswordLogin {
print_line(
account.name + ": uses a password, nothing to do (auth = \"password\")",
)
continue
}
let settings = oauth_settings_for(account, config.dir) catch {
err => raise cli_error(account.name + ": " + error_message(err))
}
let authorization = request_device_code(settings) catch {
err => raise cli_error(account.name + ": " + error_message(err))
}
print_line(account.name + ": sign in to " + settings.provider)
print_line(" 1. open " + authorization.verification_uri)
if authorization.verification_uri_complete != "" {
print_line(
" (or directly: " + authorization.verification_uri_complete + ")",
)
}
print_line(" 2. enter the code: " + authorization.user_code)
if authorization.message != "" {
print_line(" " + authorization.message)
}
print_line(
" waiting up to " +
(authorization.expires_in_s / 60).to_string() +
" minutes for approval (Ctrl-C to abort)...",
)
let mut polls = 0
let tokens = poll_device_token(settings, authorization, () => {
polls = polls + 1
}) catch {
err => raise cli_error(account.name + ": " + error_message(err))
}
if is_blank(tokens.refresh_token) {
raise cli_error(
account.name +
": the provider did not return a refresh token; check that 'offline_access' is in oauth_scope",
)
}
if polls > 1 {
print_line(" approved after " + polls.to_string() + " checks")
}
let path = token_path(account, config.dir)
save_refresh_token(path, account, tokens.refresh_token, key) catch {
err => raise cli_error(account.name + ": " + error_message(err))
}
print_line(" saved the refresh token to " + path)
// Seed the in-process cache so the verification below does not refresh
// the token a second time.
access_token_cache.set(account.name, tokens)
if opts.no_verify {
continue
}
let failure : Error? = (() => {
check_account(account, key, config.dir)
None
})() catch {
err => Some(err)
}
match failure {
Some(err) =>
raise cli_error(
account.name +
": signed in, but IMAP still fails: " +
error_message(err),
)
None => print_line(" IMAP login ok")
}
}
}
///|
async fn cmd_check(opts : CliOptions) -> Unit {
let (config, _env, key) = config_and_env(opts)
print_line("config: " + config.path)
print_line("env: " + config.env_file)
let mut any_encrypted = false
for account in config.accounts {
if needs_key(account.password) {
any_encrypted = true
}
}
print_line(
"key: " +
(if key != "" {
"loaded from " + config.key_env
} else if any_encrypted {
"missing (run `mails keygen`)"
} else {
"not needed (no encrypted passwords)"
}),
)
let accounts = selected_accounts(config, opts.account) catch {
err => raise cli_error(error_message(err))
}
let mut failures = 0
for account in accounts {
let failure : Error? = (() => {
check_account(account, key, config.dir)
None
})() catch {
err => Some(err)
}
match failure {
Some(err) => {
failures = failures + 1
print_error(account.name + ": " + error_message(err))
}
None => ()
}
}
if failures > 0 {
raise cli_error(failures.to_string() + " account(s) failed the check")
}
print_line("all accounts are reachable")
}
///|
async fn check_account(
account : Account,
key : String,
config_dir : String,
) -> Unit {
let credential = credential_for(account, config_dir, key) catch {
err =>
raise cli_error(
"cannot prepare credentials for '" +
account.name +
"': " +
error_message(err),
)
}
let client = ImapClient::connect_with(
account.host,
account.port,
account.use_tls,
account.tls_verify,
account.username,
credential,
)
let failure : Error? = (() => {
for mailbox in account.mailboxes {
let status = client.select_mailbox(mailbox)
print_line(
"ok " +
account.name +
" / " +
mailbox +
" (" +
status.exists.to_string() +
" messages, next uid " +
status.uid_next.to_string() +
")",
)
}
None
})() catch {
err => Some(err)
}
client.close() catch {
_ => ()
}
match failure {
Some(err) => raise cli_error(error_message(err))
None => ()
}
}
///|
async fn cmd_watch(opts : CliOptions) -> Unit {
let (config, _env, key) = config_and_env(opts)
if key == "" {
let mut needs_a_key = false
for account in config.accounts {
if needs_key(account.password) {
needs_a_key = true
}
}
if needs_a_key {
raise cli_error(
"no encryption key: set " +
config.key_env +
" in " +
config.env_file +
" (run `mails keygen`)",
)
}
}
let accounts = selected_accounts(config, opts.account) catch {
err => raise cli_error(error_message(err))
}
run_watch(config, accounts, key, opts.once)
}
///|
async fn cmd_version() -> Unit {
print_line("mails " + cli_version)
}
///|
async fn dispatch(opts : CliOptions) -> Unit {
if opts.help {
print_help()
return
}
match opts.command {
"" | "help" => print_help()
"watch" => cmd_watch(opts)
"sync" => {
opts.once = true
cmd_watch(opts)
}
"check" => cmd_check(opts)
"login" => cmd_login(opts)
"encrypt" => cmd_encrypt(opts)
"keygen" => cmd_keygen(opts)
"rules" => cmd_rules(opts)
"env" => cmd_env(opts)
"init" => cmd_init(opts)
"version" | "--version" => cmd_version()
other =>
raise cli_error("unknown command '" + other + "'; try `mails help`")
}
}
///|
/// Parse arguments, run the command, and report whether it succeeded.
pub async fn main_cli() -> Bool {
let argv = @env.args()
let opts = parse_args(argv) catch {
err => {
print_error(error_message(err))
return false
}
}
let failed = (() => {
dispatch(opts)
false
})() catch {
err => {
print_error(error_message(err))
true
}
}
!failed
}