(*--------------------------------------------------------------------------- Copyright (c) 2025 Anil Madhavapeddy. All rights reserved. SPDX-License-Identifier: ISC ---------------------------------------------------------------------------*) (** JMAPQ - Specialist JMAP workflow commands *) open Cmdliner (** {1 Helpers} *) let ptime_to_string t = let (y, m, d), ((hh, mm, ss), _tz) = Ptime.to_date_time t in Printf.sprintf "%04d-%02d-%02d %02d:%02d:%02d" y m d hh mm ss let truncate_string max_len s = if String.length s <= max_len then s else String.sub s 0 (max_len - 3) ^ "..." (** {1 Zulip Types and Codec} *) (** Parsed information from a Zulip notification email subject. Subject format: "#Channel > topic [Server Name]" *) module Zulip_message = struct type t = { id : string; date : Ptime.t; thread_id : string; channel : string; topic : string; server : string; is_read : bool; labels : string list; } (** Parse a Zulip subject line of the form "#Channel > topic [Server Name]" *) let parse_subject subject = (* Pattern: # > [] *) let channel_re = Re.Pcre.regexp {|^#(.+?)\s*>\s*(.+?)\s*\[(.+?)\]$|} in match Re.exec_opt channel_re subject with | Some groups -> let channel = Re.Group.get groups 1 in let topic = Re.Group.get groups 2 in let server = Re.Group.get groups 3 in Some (channel, topic, server) | None -> None (** Check if an email has the $seen keyword *) let is_seen keywords = List.exists (fun (k, v) -> k = "$seen" && v) keywords (** Extract label strings from keywords, excluding standard JMAP keywords *) let extract_labels keywords = keywords |> List.filter_map (fun (k, v) -> if v && not (String.length k > 0 && k.[0] = '$') then Some k else if v && k = "$flagged" then Some "flagged" else None) (** Create a Zulip_message from a JMAP Email *) let of_email (email : Jmap.Proto.Email.t) : t option = let id = match email.id with | Some id -> Jmap.Proto.Id.to_string id | None -> "" in let date = match email.received_at with | Some t -> t | None -> Ptime.epoch in let thread_id = match email.thread_id with | Some id -> Jmap.Proto.Id.to_string id | None -> "" in let subject = Option.value ~default:"" email.subject in match parse_subject subject with | None -> None | Some (channel, topic, server) -> let keywords = Option.value ~default:[] email.keywords in let is_read = is_seen keywords in let labels = extract_labels keywords in Some { id; date; thread_id; channel; topic; server; is_read; labels } (** Jsont codec for Ptime.t - reuse the library's UTC date codec *) let ptime_jsont : Ptime.t Jsont.t = Jmap.Proto.Date.Utc.jsont (** Jsont codec for a single Zulip message *) let jsont : t Jsont.t = let kind = "ZulipMessage" in let make id date thread_id channel topic server is_read labels = { id; date; thread_id; channel; topic; server; is_read; labels } in Jsont.Object.map ~kind make |> Jsont.Object.mem "id" Jsont.string ~enc:(fun t -> t.id) |> Jsont.Object.mem "date" ptime_jsont ~enc:(fun t -> t.date) |> Jsont.Object.mem "thread_id" Jsont.string ~enc:(fun t -> t.thread_id) |> Jsont.Object.mem "channel" Jsont.string ~enc:(fun t -> t.channel) |> Jsont.Object.mem "topic" Jsont.string ~enc:(fun t -> t.topic) |> Jsont.Object.mem "server" Jsont.string ~enc:(fun t -> t.server) |> Jsont.Object.mem "is_read" Jsont.bool ~enc:(fun t -> t.is_read) |> Jsont.Object.mem "labels" (Jsont.list Jsont.string) ~enc:(fun t -> t.labels) |> Jsont.Object.finish (** Jsont codec for a list of Zulip messages *) let list_jsont : t list Jsont.t = Jsont.list jsont end (** {1 Zulip List Command} *) let zulip_list_cmd = let json_term = let doc = "Output as JSON" in Arg.(value & flag & info ["json"] ~doc) in let limit_term = let doc = "Maximum number of messages to fetch (default: all)" in Arg.(value & opt (some int) None & info ["limit"; "n"] ~docv:"N" ~doc) in let run cfg json_output limit = Eio_main.run @@ fun env -> Eio.Switch.run @@ fun sw -> let client = Jmap_eio.Cli.create_client ~sw env cfg in let account_id = Jmap_eio.Cli.get_account_id cfg client in Jmap_eio.Cli.debug cfg "Searching for Zulip notification emails"; (* Build filter for emails from noreply@zulip.com *) let cond : Jmap.Proto.Email.Filter_condition.t = { in_mailbox = None; in_mailbox_other_than = None; before = None; after = None; min_size = None; max_size = None; all_in_thread_have_keyword = None; some_in_thread_have_keyword = None; none_in_thread_have_keyword = None; has_keyword = None; not_keyword = None; has_attachment = None; text = None; from = Some "noreply@zulip.com"; to_ = None; cc = None; bcc = None; subject = None; body = None; header = None; } in let filter = Jmap.Proto.Filter.Condition cond in let sort = [Jmap.Proto.Filter.comparator ~is_ascending:false "receivedAt"] in (* Query for all Zulip emails *) let query_limit = match limit with | Some n -> Int64.of_int n | None -> Int64.of_int 10000 (* Large default to get "all" *) in let query_inv = Jmap_eio.Client.Build.email_query ~call_id:"q1" ~account_id ~filter ~sort ~limit:query_limit () in let req = Jmap_eio.Client.Build.( make_request ~capabilities:[Jmap.Proto.Capability.core; Jmap.Proto.Capability.mail] [query_inv] ) in match Jmap_eio.Client.request client req with | Error e -> Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e); exit 1 | Ok response -> match Jmap_eio.Client.Parse.parse_email_query ~call_id:"q1" response with | Error e -> Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e); exit 1 | Ok query_result -> let email_ids = query_result.ids in Jmap_eio.Cli.debug cfg "Found %d Zulip email IDs" (List.length email_ids); if List.length email_ids = 0 then ( if json_output then Fmt.pr "[]@." else Fmt.pr "No Zulip notification emails found.@." ) else ( (* Fetch email details *) let get_inv = Jmap_eio.Client.Build.email_get ~call_id:"g1" ~account_id ~ids:email_ids ~properties:["id"; "blobId"; "threadId"; "mailboxIds"; "keywords"; "size"; "receivedAt"; "subject"; "from"] () in let req2 = Jmap_eio.Client.Build.( make_request ~capabilities:[Jmap.Proto.Capability.core; Jmap.Proto.Capability.mail] [get_inv] ) in match Jmap_eio.Client.request client req2 with | Error e -> Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e); exit 1 | Ok response2 -> match Jmap_eio.Client.Parse.parse_email_get ~call_id:"g1" response2 with | Error e -> Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e); exit 1 | Ok get_result -> (* Parse Zulip subjects and filter successful parses *) let zulip_messages = get_result.list |> List.filter_map Zulip_message.of_email in Jmap_eio.Cli.debug cfg "Parsed %d Zulip messages from %d emails" (List.length zulip_messages) (List.length get_result.list); if json_output then ( (* Output as JSON *) match Jsont_bytesrw.encode_string' ~format:Jsont.Indent Zulip_message.list_jsont zulip_messages with | Ok json_str -> Fmt.pr "%s@." json_str | Error e -> Fmt.epr "JSON encoding error: %s@." (Jsont.Error.to_string e) ) else ( (* Human-readable output *) Fmt.pr "@[%a (%d messages)@,@," Fmt.(styled `Bold string) "Zulip Notifications" (List.length zulip_messages); (* Group by server, then by channel *) let by_server = Hashtbl.create 8 in List.iter (fun (msg : Zulip_message.t) -> let existing = try Hashtbl.find by_server msg.server with Not_found -> [] in Hashtbl.replace by_server msg.server (msg :: existing) ) zulip_messages; Hashtbl.iter (fun server msgs -> Fmt.pr "%a [%s]@," Fmt.(styled `Bold string) "Server:" server; (* Group by channel within server *) let by_channel = Hashtbl.create 8 in List.iter (fun (msg : Zulip_message.t) -> let existing = try Hashtbl.find by_channel msg.channel with Not_found -> [] in Hashtbl.replace by_channel msg.channel (msg :: existing) ) msgs; Hashtbl.iter (fun channel channel_msgs -> Fmt.pr " %a #%s (%d)@," Fmt.(styled `Cyan string) "Channel:" channel (List.length channel_msgs); (* Sort by date descending *) let sorted = List.sort (fun a b -> Ptime.compare b.Zulip_message.date a.Zulip_message.date ) channel_msgs in List.iter (fun (msg : Zulip_message.t) -> let read_marker = if msg.is_read then " " else "*" in let labels_str = match msg.labels with | [] -> "" | ls -> " [" ^ String.concat ", " ls ^ "]" in Fmt.pr " %s %s %a %s%s@," read_marker (ptime_to_string msg.date) Fmt.(styled `Yellow string) (truncate_string 40 msg.topic) (truncate_string 12 msg.id) labels_str ) sorted; Fmt.pr "@," ) by_channel ) by_server; Fmt.pr "@]@." ) ) in let doc = "List Zulip notification emails with parsed channel/topic info" in let man = [ `S Manpage.s_description; `P "Lists all emails from noreply@zulip.com and parses the subject line to extract \ the Zulip channel, topic, and server name."; `P "Subject format expected: \"#Channel > topic [Server Name]\""; `S Manpage.s_examples; `P "List all Zulip notifications:"; `Pre " jmapq zulip-list"; `P "Output as JSON:"; `Pre " jmapq zulip-list --json"; `P "Limit to 50 most recent:"; `Pre " jmapq zulip-list -n 50"; ] in let info = Cmd.info "zulip-list" ~doc ~man in Cmd.v info Term.(const run $ Jmap_eio.Cli.config_term $ json_term $ limit_term) (** {1 Zulip Timeout Command} *) (** The keyword used to mark Zulip notifications as processed *) let zulip_processed_keyword = "zulip-processed" let zulip_timeout_cmd = let email_ids_term = let doc = "Email IDs to mark as processed" in Arg.(non_empty & pos_all string [] & info [] ~docv:"EMAIL_ID" ~doc) in let verbose_term = let doc = "Show the raw JMAP server response" in Arg.(value & flag & info ["v"; "verbose"] ~doc) in let run cfg verbose email_id_strs = Eio_main.run @@ fun env -> Eio.Switch.run @@ fun sw -> let client = Jmap_eio.Cli.create_client ~sw env cfg in let account_id = Jmap_eio.Cli.get_account_id cfg client in let email_ids = List.map Jmap.Proto.Id.of_string_exn email_id_strs in Jmap_eio.Cli.debug cfg "Marking %d email(s) with '%s' keyword" (List.length email_ids) zulip_processed_keyword; (* Build patch to add the zulip-processed keyword and mark as read *) let patch = let open Jmap_eio.Chain in json_obj [ ("keywords/" ^ zulip_processed_keyword, json_bool true); ("keywords/$seen", json_bool true); ] in (* Build updates list: each email ID gets the same patch *) let updates = List.map (fun id -> (id, patch)) email_ids in let open Jmap_eio.Chain in let request, set_h = build ~capabilities:[Jmap.Proto.Capability.core; Jmap.Proto.Capability.mail] begin email_set ~account_id ~update:updates () end in match Jmap_eio.Client.request client request with | Error e -> Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e); exit 1 | Ok response -> (* Print raw response if verbose *) if verbose then begin Fmt.pr "@[%a:@," Fmt.(styled `Bold string) "Server Response"; (match Jsont_bytesrw.encode_string' ~format:Jsont.Indent Jmap.Proto.Response.jsont response with | Ok json_str -> Fmt.pr "%s@,@]@." json_str | Error e -> Fmt.epr "JSON encoding error: %s@." (Jsont.Error.to_string e)) end; (* Check for JMAP method-level errors first *) let call_id = Jmap_eio.Chain.call_id set_h in (match Jmap.Proto.Response.find_response call_id response with | None -> Fmt.epr "Error: No response found for call_id %s@." call_id; exit 1 | Some inv when Jmap.Proto.Response.is_error inv -> (match Jmap.Proto.Response.get_error inv with | Some err -> Fmt.epr "JMAP Error: %s%s@." (Jmap.Proto.Error.method_error_type_to_string err.type_) (match err.description with Some d -> " - " ^ d | None -> ""); exit 1 | None -> Fmt.epr "JMAP Error: Unknown error@."; exit 1) | Some _ -> match parse set_h response with | Error e -> Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e); exit 1 | Ok result -> (* Report successes *) let updated_ids = result.updated |> Option.value ~default:[] |> List.map (fun (id, _) -> Jmap.Proto.Id.to_string id) in if List.length updated_ids > 0 then begin Fmt.pr "@[%a %d email(s) as read with '%s':@," Fmt.(styled `Green string) "Marked" (List.length updated_ids) zulip_processed_keyword; List.iter (fun id -> Fmt.pr " %s@," id) updated_ids; Fmt.pr "@]@." end; (* Report failures *) let not_updated = Option.value ~default:[] result.not_updated in if not_updated <> [] then begin Fmt.epr "@[%a to mark %d email(s):@," Fmt.(styled `Red string) "Failed" (List.length not_updated); List.iter (fun (id, err) -> let open Jmap.Proto.Error in let err_type = set_error_type_to_string err.type_ in let err_desc = Option.value ~default:"" err.description in Fmt.epr " %s: %s%s@," (Jmap.Proto.Id.to_string id) err_type (if err_desc = "" then "" else " - " ^ err_desc) ) not_updated; Fmt.epr "@]@."; exit 1 end) in let doc = "Mark Zulip notification emails as processed" in let man = [ `S Manpage.s_description; `P (Printf.sprintf "Adds the '%s' keyword to the specified email(s). \ This keyword can be used to filter processed Zulip notifications \ or set up server-side rules to auto-archive them." zulip_processed_keyword); `S Manpage.s_examples; `P "Mark a single email as processed:"; `Pre " jmapq zulip-timeout StrrDTS_WEa3"; `P "Mark multiple emails as processed:"; `Pre " jmapq zulip-timeout StrrDTS_WEa3 StrsGZ7P8Dpc StrsGuCSXJ3Z"; ] in let info = Cmd.info "zulip-timeout" ~doc ~man in Cmd.v info Term.(const run $ Jmap_eio.Cli.config_term $ verbose_term $ email_ids_term) (** {1 Zulip View Command} *) let zulip_view_cmd = let json_term = let doc = "Output as JSON" in Arg.(value & flag & info ["json"] ~doc) in let limit_term = let doc = "Maximum number of messages to fetch (default: all)" in Arg.(value & opt (some int) None & info ["limit"; "n"] ~docv:"N" ~doc) in let verbose_term = let doc = "Show the raw JMAP request and response" in Arg.(value & flag & info ["v"; "verbose"] ~doc) in let run cfg json_output limit verbose = Eio_main.run @@ fun env -> Eio.Switch.run @@ fun sw -> let client = Jmap_eio.Cli.create_client ~sw env cfg in let account_id = Jmap_eio.Cli.get_account_id cfg client in Jmap_eio.Cli.debug cfg "Searching for Zulip emails marked as processed"; (* Build filter for emails from noreply@zulip.com with zulip-processed keyword *) let cond : Jmap.Proto.Email.Filter_condition.t = { in_mailbox = None; in_mailbox_other_than = None; before = None; after = None; min_size = None; max_size = None; all_in_thread_have_keyword = None; some_in_thread_have_keyword = None; none_in_thread_have_keyword = None; has_keyword = Some zulip_processed_keyword; not_keyword = None; has_attachment = None; text = None; from = Some "noreply@zulip.com"; to_ = None; cc = None; bcc = None; subject = None; body = None; header = None; } in let filter = Jmap.Proto.Filter.Condition cond in let sort = [Jmap.Proto.Filter.comparator ~is_ascending:false "receivedAt"] in (* Query for processed Zulip emails *) let query_limit = match limit with | Some n -> Int64.of_int n | None -> Int64.of_int 10000 in let query_inv = Jmap_eio.Client.Build.email_query ~call_id:"q1" ~account_id ~filter ~sort ~limit:query_limit () in let req = Jmap_eio.Client.Build.( make_request ~capabilities:[Jmap.Proto.Capability.core; Jmap.Proto.Capability.mail] [query_inv] ) in (* Print request if verbose *) if verbose then begin Fmt.pr "@[%a:@," Fmt.(styled `Bold string) "Request"; (match Jsont_bytesrw.encode_string' ~format:Jsont.Indent Jmap.Proto.Request.jsont req with | Ok json_str -> Fmt.pr "%s@,@]@." json_str | Error e -> Fmt.epr "JSON encoding error: %s@." (Jsont.Error.to_string e)) end; match Jmap_eio.Client.request client req with | Error e -> Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e); exit 1 | Ok response -> (* Print response if verbose *) if verbose then begin Fmt.pr "@[%a:@," Fmt.(styled `Bold string) "Response"; (match Jsont_bytesrw.encode_string' ~format:Jsont.Indent Jmap.Proto.Response.jsont response with | Ok json_str -> Fmt.pr "%s@,@]@." json_str | Error e -> Fmt.epr "JSON encoding error: %s@." (Jsont.Error.to_string e)) end; match Jmap_eio.Client.Parse.parse_email_query ~call_id:"q1" response with | Error e -> Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e); exit 1 | Ok query_result -> let email_ids = query_result.ids in Jmap_eio.Cli.debug cfg "Found %d processed Zulip email IDs" (List.length email_ids); if List.length email_ids = 0 then ( if json_output then Fmt.pr "[]@." else Fmt.pr "No Zulip emails marked as processed.@." ) else ( (* Fetch email details *) let get_inv = Jmap_eio.Client.Build.email_get ~call_id:"g1" ~account_id ~ids:email_ids ~properties:["id"; "blobId"; "threadId"; "mailboxIds"; "keywords"; "size"; "receivedAt"; "subject"; "from"] () in let req2 = Jmap_eio.Client.Build.( make_request ~capabilities:[Jmap.Proto.Capability.core; Jmap.Proto.Capability.mail] [get_inv] ) in match Jmap_eio.Client.request client req2 with | Error e -> Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e); exit 1 | Ok response2 -> match Jmap_eio.Client.Parse.parse_email_get ~call_id:"g1" response2 with | Error e -> Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e); exit 1 | Ok get_result -> (* Parse Zulip subjects and filter successful parses *) let zulip_messages = get_result.list |> List.filter_map Zulip_message.of_email in Jmap_eio.Cli.debug cfg "Parsed %d Zulip messages from %d emails" (List.length zulip_messages) (List.length get_result.list); if json_output then ( (* Output as JSON *) match Jsont_bytesrw.encode_string' ~format:Jsont.Indent Zulip_message.list_jsont zulip_messages with | Ok json_str -> Fmt.pr "%s@." json_str | Error e -> Fmt.epr "JSON encoding error: %s@." (Jsont.Error.to_string e) ) else ( (* Human-readable output *) Fmt.pr "@[%a (%d messages)@,@," Fmt.(styled `Bold string) "Processed Zulip Notifications" (List.length zulip_messages); (* Group by server, then by channel *) let by_server = Hashtbl.create 8 in List.iter (fun (msg : Zulip_message.t) -> let existing = try Hashtbl.find by_server msg.server with Not_found -> [] in Hashtbl.replace by_server msg.server (msg :: existing) ) zulip_messages; Hashtbl.iter (fun server msgs -> Fmt.pr "%a [%s]@," Fmt.(styled `Bold string) "Server:" server; (* Group by channel within server *) let by_channel = Hashtbl.create 8 in List.iter (fun (msg : Zulip_message.t) -> let existing = try Hashtbl.find by_channel msg.channel with Not_found -> [] in Hashtbl.replace by_channel msg.channel (msg :: existing) ) msgs; Hashtbl.iter (fun channel channel_msgs -> Fmt.pr " %a #%s (%d)@," Fmt.(styled `Cyan string) "Channel:" channel (List.length channel_msgs); (* Sort by date descending *) let sorted = List.sort (fun a b -> Ptime.compare b.Zulip_message.date a.Zulip_message.date ) channel_msgs in List.iter (fun (msg : Zulip_message.t) -> let read_marker = if msg.is_read then " " else "*" in let labels_str = match msg.labels with | [] -> "" | ls -> " [" ^ String.concat ", " ls ^ "]" in Fmt.pr " %s %s %a %s%s@," read_marker (ptime_to_string msg.date) Fmt.(styled `Yellow string) (truncate_string 40 msg.topic) (truncate_string 12 msg.id) labels_str ) sorted; Fmt.pr "@," ) by_channel ) by_server; Fmt.pr "@]@." ) ) in let doc = "List Zulip emails that have been marked as processed" in let man = [ `S Manpage.s_description; `P (Printf.sprintf "Lists all Zulip notification emails that have the '%s' keyword." zulip_processed_keyword); `S Manpage.s_examples; `P "List all processed Zulip notifications:"; `Pre " jmapq zulip-view"; `P "Output as JSON:"; `Pre " jmapq zulip-view --json"; `P "Limit to 50 most recent:"; `Pre " jmapq zulip-view -n 50"; ] in let info = Cmd.info "zulip-view" ~doc ~man in Cmd.v info Term.(const run $ Jmap_eio.Cli.config_term $ json_term $ limit_term $ verbose_term) (** {1 Main Command Group} *) let main_cmd = let doc = "JMAPQ - Specialist JMAP workflow commands" in let man = [ `S Manpage.s_description; `P "A collection of specialist workflow commands for JMAP email processing."; `S Manpage.s_environment; `P Jmap_eio.Cli.env_docs; ] in let info = Cmd.info "jmapq" ~version:"0.1.0" ~doc ~man in Cmd.group info [ zulip_list_cmd; zulip_timeout_cmd; zulip_view_cmd; ] let () = Fmt_tty.setup_std_outputs (); exit (Cmd.eval main_cmd)