(*--------------------------------------------------------------------------- Copyright (c) 2025 Anil Madhavapeddy. All rights reserved. SPDX-License-Identifier: ISC ---------------------------------------------------------------------------*) (** JMAP CLI configuration and cmdliner terms *) open Cmdliner (** {1 Configuration Types} *) type source = Default | Env of string | Cmdline type config = { session_url : string; session_url_source : source; api_key : string; api_key_source : source; account_id : string option; account_id_source : source; debug : bool; } (** {1 Pretty Printing} *) let pp_source ppf = function | Default -> Fmt.(styled `Faint string) ppf "default" | Env var -> Fmt.pf ppf "%a" Fmt.(styled `Yellow string) ("env(" ^ var ^ ")") | Cmdline -> Fmt.(styled `Blue string) ppf "cmdline" let pp_config ppf cfg = let pp_field name value source = Fmt.pf ppf "@,%a %a %a" Fmt.(styled `Cyan string) (name ^ ":") Fmt.(styled `Green string) value Fmt.(styled `Faint (brackets pp_source)) source in let pp_opt_field name value_opt source = match value_opt with | None -> () | Some value -> pp_field name value source in Fmt.pf ppf "@[%a" Fmt.(styled `Bold string) "JMAP config:"; pp_field "session_url" cfg.session_url cfg.session_url_source; pp_field "api_key" (String.make (min 8 (String.length cfg.api_key)) '*' ^ "...") cfg.api_key_source; pp_opt_field "account_id" cfg.account_id cfg.account_id_source; Fmt.pf ppf "@]" (** {1 Cmdliner Terms} *) let env_var_name suffix = "JMAP_" ^ suffix let resolve_with_env ~cmdline ~env_var ~default = match cmdline with | Some v -> (v, Cmdline) | None -> match Sys.getenv_opt env_var with | Some v when v <> "" -> (v, Env env_var) | _ -> (default, Default) let resolve_opt_with_env ~cmdline ~env_var = match cmdline with | Some v -> (Some v, Cmdline) | None -> match Sys.getenv_opt env_var with | Some v when v <> "" -> (Some v, Env env_var) | _ -> (None, Default) (** Session URL term *) let session_url_term = let doc = Printf.sprintf "JMAP session URL. Can also be set with %s environment variable." (env_var_name "SESSION_URL") in Arg.(value & opt (some string) None & info ["url"; "u"] ~docv:"URL" ~doc) (** API key term *) let api_key_term = let doc = Printf.sprintf "JMAP API key or Bearer token. Can also be set with %s environment variable." (env_var_name "API_KEY") in Arg.(value & opt (some string) None & info ["api-key"; "k"] ~docv:"KEY" ~doc) (** API key file term *) let api_key_file_term = let doc = Printf.sprintf "File containing JMAP API key. Can also be set with %s environment variable." (env_var_name "API_KEY_FILE") in Arg.(value & opt (some string) None & info ["api-key-file"; "K"] ~docv:"FILE" ~doc) (** Account ID term *) let account_id_term = let doc = Printf.sprintf "Account ID to use (defaults to primary mail account). Can also be set with %s." (env_var_name "ACCOUNT_ID") in Arg.(value & opt (some string) None & info ["account"; "a"] ~docv:"ID" ~doc) (** Debug flag term *) let debug_term = let doc = "Enable debug output" in Arg.(value & flag & info ["debug"; "d"] ~doc) (** Read API key from file *) let read_api_key_file path = try let ic = open_in path in let key = input_line ic in close_in ic; String.trim key with | Sys_error msg -> failwith (Printf.sprintf "Cannot read API key file: %s" msg) | End_of_file -> failwith "API key file is empty" (** Combined configuration term *) let config_term = let make session_url_opt api_key_opt api_key_file_opt account_id_opt debug = (* Resolve session URL *) let session_url, session_url_source = resolve_with_env ~cmdline:session_url_opt ~env_var:(env_var_name "SESSION_URL") ~default:"" in if session_url = "" then failwith "Session URL is required. Set via --url or JMAP_SESSION_URL"; (* Resolve API key - check key file first, then direct key *) let api_key, api_key_source = match api_key_file_opt with | Some path -> (read_api_key_file path, Cmdline) | None -> match Sys.getenv_opt (env_var_name "API_KEY_FILE") with | Some path when path <> "" -> (read_api_key_file path, Env (env_var_name "API_KEY_FILE")) | _ -> resolve_with_env ~cmdline:api_key_opt ~env_var:(env_var_name "API_KEY") ~default:"" in if api_key = "" then failwith "API key is required. Set via --api-key, --api-key-file, JMAP_API_KEY, or JMAP_API_KEY_FILE"; (* Resolve account ID (optional) *) let account_id, account_id_source = resolve_opt_with_env ~cmdline:account_id_opt ~env_var:(env_var_name "ACCOUNT_ID") in { session_url; session_url_source; api_key; api_key_source; account_id; account_id_source; debug } in Term.(const make $ session_url_term $ api_key_term $ api_key_file_term $ account_id_term $ debug_term) (** {1 Environment Documentation} *) let env_docs = {| Environment Variables: JMAP_SESSION_URL JMAP session URL (e.g., https://api.fastmail.com/jmap/session) JMAP_API_KEY API key or Bearer token for authentication JMAP_API_KEY_FILE Path to file containing API key JMAP_ACCOUNT_ID Account ID to use (optional, defaults to primary mail account) Configuration Precedence: 1. Command-line flags (e.g., --url, --api-key) 2. Environment variables (e.g., JMAP_SESSION_URL) Example: export JMAP_SESSION_URL="https://api.fastmail.com/jmap/session" export JMAP_API_KEY_FILE="$HOME/.jmap-api-key" jmap emails --limit 10 |} (** {1 Client Helpers} *) let create_client ~sw env cfg = let requests = Requests.create ~sw env in let auth = Requests.Auth.bearer ~token:cfg.api_key in match Client.create_from_url ~auth requests cfg.session_url with | Error e -> Fmt.epr "@[%a Failed to connect: %s@]@." Fmt.(styled `Red string) "Error:" (Client.error_to_string e); exit 1 | Ok client -> client let get_account_id cfg client = match cfg.account_id with | Some id -> Jmap.Proto.Id.of_string_exn id | None -> let session = Client.session client in match Jmap.Proto.Session.primary_account_for Jmap.Proto.Capability.mail session with | Some id -> id | None -> Fmt.epr "@[%a No primary mail account found. Specify --account.@]@." Fmt.(styled `Red string) "Error:"; exit 1 let debug cfg fmt = if cfg.debug then Fmt.kpf (fun ppf -> Fmt.pf ppf "@.") Fmt.stderr ("@[[DEBUG] " ^^ fmt ^^ "@]") else Format.ikfprintf ignore Format.err_formatter fmt