this repo has no description

Add improved logging and unread/labels options to fastmail-list

- Add proper OCaml Logs-based logging system with configurable levels
- Redact sensitive API tokens for security in logs
- Add -unread flag to filter and only show unread messages
- Add -labels flag to display message keywords/labels
- Add -debug flag to control verbosity level
- Improve CLI help and usage instructions

🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>

Changed files
+215 -42
bin
lib
+1 -1
bin/dune
···
(public_name fastmail-list)
(package jmap)
(modules fastmail_list)
-
(libraries jmap jmap_mail lwt.unix))
+
(libraries jmap jmap_mail lwt.unix logs logs.fmt))
+94 -15
bin/fastmail_list.ml
···
*
* This binary connects to the Fastmail JMAP API using an authentication token
* from the JMAP_API_TOKEN environment variable and lists the most recent 100
-
* emails with their subjects and sender details.
+
* emails with their subjects, sender details, and labels.
*
* Usage:
-
* JMAP_API_TOKEN=your_api_token ./fastmail_list
+
* JMAP_API_TOKEN=your_api_token ./fastmail_list [options]
+
*
+
* Options:
+
* -unread List only unread messages
+
* -labels Show labels/keywords associated with messages
+
* -debug LEVEL Set debug level (0-4, where 4 is most verbose)
*)
open Lwt.Syntax
···
module Mail = Jmap_mail.Types
(** Prints the email details *)
-
let print_email (email : Mail.email) =
+
let print_email ~show_labels (email : Mail.email) =
let sender =
match email.from with
| Some (addr :: _) ->
···
| None -> "<no subject>"
in
let date = email.received_at in
-
Printf.printf "%s | %s | %s\n" date sender subject
+
+
(* Format labels/keywords if requested *)
+
let labels_str =
+
if show_labels then
+
let active_keywords =
+
List.filter_map (fun (keyword, active) ->
+
if active then Some (Jmap_mail.Json.string_of_keyword keyword) else None
+
) email.keywords
+
in
+
if List.length active_keywords > 0 then
+
" [" ^ String.concat ", " active_keywords ^ "]"
+
else
+
""
+
else
+
""
+
in
+
+
Printf.printf "%s | %s | %s%s\n" date sender subject labels_str
+
+
(** Check if an email is unread *)
+
let is_unread (email : Mail.email) =
+
let is_unread_keyword =
+
List.exists (fun (kw, active) ->
+
kw = Mail.Unread && active
+
) email.keywords
+
in
+
let is_not_seen =
+
not (List.exists (fun (kw, active) ->
+
kw = Mail.Seen && active
+
) email.keywords)
+
in
+
is_unread_keyword || is_not_seen
(** Main function *)
let main () =
+
(* Parse command-line arguments *)
+
let unread_only = ref false in
+
let show_labels = ref false in
+
let debug_level = ref 0 in
+
+
let args = [
+
("-unread", Arg.Set unread_only, "List only unread messages");
+
("-labels", Arg.Set show_labels, "Show labels/keywords associated with messages");
+
("-debug", Arg.Int (fun level -> debug_level := level), "Set debug level (0-4, where 4 is most verbose)");
+
] in
+
+
let usage_msg = "Usage: JMAP_API_TOKEN=your_token fastmail_list [options]" in
+
Arg.parse args (fun _ -> ()) usage_msg;
+
+
(* Configure logging *)
+
init_logging ~level:!debug_level ~enable_logs:(!debug_level > 0) ~redact_sensitive:true ();
+
match Sys.getenv_opt "JMAP_API_TOKEN" with
| None ->
Printf.eprintf "Error: JMAP_API_TOKEN environment variable not set\n";
-
Printf.eprintf "Usage: JMAP_API_TOKEN=your_token ./fastmail_list\n";
+
Printf.eprintf "Usage: JMAP_API_TOKEN=your_token ./fastmail_list [options]\n";
+
Printf.eprintf "Options:\n";
+
Printf.eprintf " -unread List only unread messages\n";
+
Printf.eprintf " -labels Show labels/keywords associated with messages\n";
+
Printf.eprintf " -debug LEVEL Set debug level (0-4, where 4 is most verbose)\n";
exit 1
| Some token ->
-
Printf.printf "Using API token: %s\n" token;
+
(* Only print token info at Info level or higher *)
+
Logs.info (fun m -> m "Using API token: %s" (redact_token token));
+
(* Connect to Fastmail JMAP API *)
-
(* Check token format and print helpful messages *)
let formatted_token = token in
-
Printf.printf "\nFastmail API Instructions:\n";
-
Printf.printf "1. Get a token from: https://app.fastmail.com/settings/tokens\n";
-
Printf.printf "2. Create a new token with Mail scope (read/write)\n";
-
Printf.printf "3. Copy the full token (example: 3de40-5fg1h2-a1b2c3...)\n";
-
Printf.printf "4. Run: env JMAP_API_TOKEN=\"your_full_token\" opam exec -- dune exec bin/fastmail_list.exe\n\n";
-
Printf.printf "Note: This example is working correctly but needs a valid Fastmail token.\n\n";
+
+
(* Only print instructions at Info level *)
+
let level = match Logs.level () with
+
| None -> 0
+
| Some Logs.Error -> 1
+
| Some Logs.Info -> 2
+
| Some Logs.Debug -> 3
+
| _ -> 2
+
in
+
if level >= 2 then begin
+
Printf.printf "\nFastmail API Instructions:\n";
+
Printf.printf "1. Get a token from: https://app.fastmail.com/settings/tokens\n";
+
Printf.printf "2. Create a new token with Mail scope (read/write)\n";
+
Printf.printf "3. Copy the full token (example: 3de40-5fg1h2-a1b2c3...)\n";
+
Printf.printf "4. Run: env JMAP_API_TOKEN=\"your_full_token\" opam exec -- dune exec bin/fastmail_list.exe [options]\n\n";
+
Printf.printf "Note: This example is working correctly but needs a valid Fastmail token.\n\n";
+
end;
let* result = login_with_token
~uri:"https://api.fastmail.com/jmap/session"
~api_token:formatted_token
···
| Api.Authentication_error -> "Authentication error");
Lwt.return 1
| Ok emails ->
-
Printf.printf "Listing the most recent %d emails in your inbox:\n" (List.length emails);
+
(* Filter emails if unread-only mode is enabled *)
+
let filtered_emails =
+
if !unread_only then
+
List.filter is_unread emails
+
else
+
emails
+
in
+
+
Printf.printf "Listing %s %d emails in your inbox:\n"
+
(if !unread_only then "unread" else "the most recent")
+
(List.length filtered_emails);
Printf.printf "--------------------------------------------\n";
-
List.iter print_email emails;
+
List.iter (print_email ~show_labels:!show_labels) filtered_emails;
Lwt.return 0
(** Program entry point *)
+1 -1
lib/dune
···
(name jmap)
(public_name jmap)
(modules jmap)
-
(libraries str ezjsonm ptime cohttp cohttp-lwt-unix uri lwt))
+
(libraries str ezjsonm ptime cohttp cohttp-lwt-unix uri lwt logs logs.fmt))
(library
(name jmap_mail)
+113 -25
lib/jmap.ml
···
* https://datatracker.ietf.org/doc/html/rfc8620
*)
+
(** Whether to redact sensitive information *)
+
let should_redact_sensitive = ref true
+
+
(** Initialize and configure logging for JMAP *)
+
let init_logging ?(level=2) ?(enable_logs=true) ?(redact_sensitive=true) () =
+
if enable_logs then begin
+
Logs.set_reporter (Logs.format_reporter ());
+
match level with
+
| 0 -> Logs.set_level None
+
| 1 -> Logs.set_level (Some Logs.Error)
+
| 2 -> Logs.set_level (Some Logs.Info)
+
| 3 -> Logs.set_level (Some Logs.Debug)
+
| _ -> Logs.set_level (Some Logs.Debug)
+
end else
+
Logs.set_level None;
+
should_redact_sensitive := redact_sensitive
+
+
(** Redact sensitive data like tokens *)
+
let redact_token ?(redact=true) token =
+
if redact && !should_redact_sensitive && String.length token > 8 then
+
let prefix = String.sub token 0 4 in
+
let suffix = String.sub token (String.length token - 4) 4 in
+
prefix ^ "..." ^ suffix
+
else
+
token
+
+
(** Redact sensitive headers like Authorization *)
+
let redact_headers headers =
+
List.map (fun (k, v) ->
+
if String.lowercase_ascii k = "authorization" then
+
if !should_redact_sensitive then
+
let parts = String.split_on_char ' ' v in
+
match parts with
+
| scheme :: token :: _ -> (k, scheme ^ " " ^ redact_token token)
+
| _ -> (k, v)
+
else (k, v)
+
else (k, v)
+
) headers
+
+
(* Initialize logging with defaults *)
+
let () = init_logging ()
+
(** Module for managing JMAP capability URIs and other constants *)
module Capability = struct
(** Core JMAP capability URI *)
···
let open Cohttp_lwt_unix in
let headers = Header.add_list (Header.init ()) headers in
-
(* Debug: print request details *)
-
Printf.printf "\n===== HTTP REQUEST =====\n";
-
Printf.printf "URI: %s\n" (Uri.to_string uri);
-
Printf.printf "METHOD: %s\n" method_;
-
Printf.printf "HEADERS:\n";
-
Header.iter (fun k v -> Printf.printf " %s: %s\n" k v) headers;
-
Printf.printf "BODY:\n%s\n" body;
-
Printf.printf "======================\n\n";
+
(* Log request details at debug level *)
+
let header_list = Cohttp.Header.to_list headers in
+
let redacted_headers = redact_headers header_list in
+
Logs.debug (fun m ->
+
m "\n===== HTTP REQUEST =====\n\
+
URI: %s\n\
+
METHOD: %s\n\
+
HEADERS:\n%s\n\
+
BODY:\n%s\n\
+
======================\n"
+
(Uri.to_string uri)
+
method_
+
(String.concat "\n" (List.map (fun (k, v) -> Printf.sprintf " %s: %s" k v) redacted_headers))
+
body);
Lwt.catch
(fun () ->
···
let* body_str = Cohttp_lwt.Body.to_string body in
let status = Response.status resp |> Code.code_of_status in
-
(* Debug: print response details *)
-
Printf.printf "\n===== HTTP RESPONSE =====\n";
-
Printf.printf "STATUS: %d\n" status;
-
Printf.printf "HEADERS:\n";
-
Response.headers resp |> Header.iter (fun k v -> Printf.printf " %s: %s\n" k v);
-
Printf.printf "BODY:\n%s\n" body_str;
-
Printf.printf "========================\n\n";
+
(* Log response details at debug level *)
+
let header_list = Cohttp.Header.to_list (Response.headers resp) in
+
let redacted_headers = redact_headers header_list in
+
Logs.debug (fun m ->
+
m "\n===== HTTP RESPONSE =====\n\
+
STATUS: %d\n\
+
HEADERS:\n%s\n\
+
BODY:\n%s\n\
+
======================\n"
+
status
+
(String.concat "\n" (List.map (fun (k, v) -> Printf.sprintf " %s: %s" k v) redacted_headers))
+
body_str);
if status >= 200 && status < 300 then
Lwt.return (Ok body_str)
···
Lwt.return (Error (HTTP_error (status, body_str))))
(fun e ->
let error_msg = Printexc.to_string e in
-
Printf.printf "\n===== HTTP ERROR =====\n%s\n======================\n\n" error_msg;
+
Logs.err (fun m -> m "%s" error_msg);
Lwt.return (Error (Connection_error error_msg)))
(** Make a raw JMAP API request
···
(* API token (bearer authentication) *)
"Bearer " ^ config.authentication_token
in
-
Printf.printf "Using authorization header: %s\n" auth_header;
+
+
(* Log auth header at debug level with redaction *)
+
let redacted_header =
+
if String.length config.username > 0 then
+
"Basic " ^ redact_token (Base64.encode_string (config.username ^ ":" ^ config.authentication_token))
+
else
+
"Bearer " ^ redact_token config.authentication_token
+
in
+
Logs.debug (fun m -> m "Using authorization header: %s" redacted_header);
+
let headers = [
("Content-Type", "application/json");
("Content-Length", string_of_int (String.length body));
···
match result with
| Ok response_body ->
(match parse_json_string response_body with
-
| Ok json -> Lwt.return (parse_response json)
-
| Error e -> Lwt.return (Error e))
-
| Error e -> Lwt.return (Error e)
+
| Ok json ->
+
Logs.debug (fun m -> m "Successfully parsed JSON response");
+
Lwt.return (parse_response json)
+
| Error e ->
+
let msg = match e with Parse_error m -> m | _ -> "unknown error" in
+
Logs.err (fun m -> m "Failed to parse response: %s" msg);
+
Lwt.return (Error e))
+
| Error e ->
+
(match e with
+
| Connection_error msg -> Logs.err (fun m -> m "Connection error: %s" msg)
+
| HTTP_error (code, _) -> Logs.err (fun m -> m "HTTP error %d" code)
+
| Parse_error msg -> Logs.err (fun m -> m "Parse error: %s" msg)
+
| Authentication_error -> Logs.err (fun m -> m "Authentication error"));
+
Lwt.return (Error e)
(** Parse a JSON object as a Session object *)
let parse_session_object json =
···
match (username, authentication_token, api_token) with
| (Some u, Some t, _) ->
let auth = "Basic " ^ Base64.encode_string (u ^ ":" ^ t) in
-
Printf.printf "Session using Basic auth: %s\n" auth;
+
let redacted_auth = "Basic " ^ redact_token (Base64.encode_string (u ^ ":" ^ t)) in
+
Logs.info (fun m -> m "Session using Basic auth: %s" redacted_auth);
[
("Content-Type", "application/json");
("Authorization", auth)
]
| (_, _, Some token) ->
let auth = "Bearer " ^ token in
-
Printf.printf "Session using Bearer auth: %s\n" auth;
+
let redacted_token = redact_token token in
+
Logs.info (fun m -> m "Session using Bearer auth: %s" ("Bearer " ^ redacted_token));
[
("Content-Type", "application/json");
("Authorization", auth)
···
match result with
| Ok response_body ->
(match parse_json_string response_body with
-
| Ok json -> Lwt.return (parse_session_object json)
-
| Error e -> Lwt.return (Error e))
-
| Error e -> Lwt.return (Error e)
+
| Ok json ->
+
Logs.debug (fun m -> m "Successfully parsed session response");
+
Lwt.return (parse_session_object json)
+
| Error e ->
+
let msg = match e with Parse_error m -> m | _ -> "unknown error" in
+
Logs.err (fun m -> m "Failed to parse session response: %s" msg);
+
Lwt.return (Error e))
+
| Error e ->
+
let err_msg = match e with
+
| Connection_error msg -> "Connection error: " ^ msg
+
| HTTP_error (code, _) -> Printf.sprintf "HTTP error %d" code
+
| Parse_error msg -> "Parse error: " ^ msg
+
| Authentication_error -> "Authentication error"
+
in
+
Logs.err (fun m -> m "Failed to get session: %s" err_msg);
+
Lwt.return (Error e)
(** Upload a binary blob to the server
+6
lib/jmap.mli
···
* https://datatracker.ietf.org/doc/html/rfc8620
*)
+
(** Initialize and configure logging for JMAP *)
+
val init_logging : ?level:int -> ?enable_logs:bool -> ?redact_sensitive:bool -> unit -> unit
+
+
(** Redact sensitive data like tokens *)
+
val redact_token : ?redact:bool -> string -> string
+
(** Module for managing JMAP capability URIs and other constants *)
module Capability : sig
(** Core JMAP capability URI *)