this repo has no description
at main 27 kB view raw
1(*--------------------------------------------------------------------------- 2 Copyright (c) 2025 Anil Madhavapeddy. All rights reserved. 3 SPDX-License-Identifier: ISC 4 ---------------------------------------------------------------------------*) 5 6(** JMAPQ - Specialist JMAP workflow commands *) 7 8open Cmdliner 9 10(** {1 Helpers} *) 11 12let ptime_to_string t = 13 let (y, m, d), ((hh, mm, ss), _tz) = Ptime.to_date_time t in 14 Printf.sprintf "%04d-%02d-%02d %02d:%02d:%02d" y m d hh mm ss 15 16let truncate_string max_len s = 17 if String.length s <= max_len then s 18 else String.sub s 0 (max_len - 3) ^ "..." 19 20(** {1 Zulip Types and Codec} *) 21 22(** Parsed information from a Zulip notification email subject. 23 Subject format: "#Channel > topic [Server Name]" *) 24module Zulip_message = struct 25 type t = { 26 id : string; 27 date : Ptime.t; 28 thread_id : string; 29 channel : string; 30 topic : string; 31 server : string; 32 is_read : bool; 33 labels : string list; 34 } 35 36 (** Parse a Zulip subject line of the form "#Channel > topic [Server Name]" *) 37 let parse_subject subject = 38 (* Pattern: #<channel> > <topic> [<server>] *) 39 let channel_re = Re.Pcre.regexp {|^#(.+?)\s*>\s*(.+?)\s*\[(.+?)\]$|} in 40 match Re.exec_opt channel_re subject with 41 | Some groups -> 42 let channel = Re.Group.get groups 1 in 43 let topic = Re.Group.get groups 2 in 44 let server = Re.Group.get groups 3 in 45 Some (channel, topic, server) 46 | None -> None 47 48 (** Check if an email has the $seen keyword *) 49 let is_seen keywords = 50 List.exists (fun (k, v) -> k = "$seen" && v) keywords 51 52 (** Extract label strings from keywords, excluding standard JMAP keywords *) 53 let extract_labels keywords = 54 keywords 55 |> List.filter_map (fun (k, v) -> 56 if v && not (String.length k > 0 && k.[0] = '$') then 57 Some k 58 else if v && k = "$flagged" then 59 Some "flagged" 60 else 61 None) 62 63 (** Create a Zulip_message from a JMAP Email *) 64 let of_email (email : Jmap.Proto.Email.t) : t option = 65 let id = match email.id with 66 | Some id -> Jmap.Proto.Id.to_string id 67 | None -> "" 68 in 69 let date = match email.received_at with 70 | Some t -> t 71 | None -> Ptime.epoch 72 in 73 let thread_id = match email.thread_id with 74 | Some id -> Jmap.Proto.Id.to_string id 75 | None -> "" 76 in 77 let subject = Option.value ~default:"" email.subject in 78 match parse_subject subject with 79 | None -> None 80 | Some (channel, topic, server) -> 81 let keywords = Option.value ~default:[] email.keywords in 82 let is_read = is_seen keywords in 83 let labels = extract_labels keywords in 84 Some { id; date; thread_id; channel; topic; server; is_read; labels } 85 86 (** Jsont codec for Ptime.t - reuse the library's UTC date codec *) 87 let ptime_jsont : Ptime.t Jsont.t = Jmap.Proto.Date.Utc.jsont 88 89 (** Jsont codec for a single Zulip message *) 90 let jsont : t Jsont.t = 91 let kind = "ZulipMessage" in 92 let make id date thread_id channel topic server is_read labels = 93 { id; date; thread_id; channel; topic; server; is_read; labels } 94 in 95 Jsont.Object.map ~kind make 96 |> Jsont.Object.mem "id" Jsont.string ~enc:(fun t -> t.id) 97 |> Jsont.Object.mem "date" ptime_jsont ~enc:(fun t -> t.date) 98 |> Jsont.Object.mem "thread_id" Jsont.string ~enc:(fun t -> t.thread_id) 99 |> Jsont.Object.mem "channel" Jsont.string ~enc:(fun t -> t.channel) 100 |> Jsont.Object.mem "topic" Jsont.string ~enc:(fun t -> t.topic) 101 |> Jsont.Object.mem "server" Jsont.string ~enc:(fun t -> t.server) 102 |> Jsont.Object.mem "is_read" Jsont.bool ~enc:(fun t -> t.is_read) 103 |> Jsont.Object.mem "labels" (Jsont.list Jsont.string) ~enc:(fun t -> t.labels) 104 |> Jsont.Object.finish 105 106 (** Jsont codec for a list of Zulip messages *) 107 let list_jsont : t list Jsont.t = Jsont.list jsont 108end 109 110(** {1 Zulip List Command} *) 111 112let zulip_list_cmd = 113 let json_term = 114 let doc = "Output as JSON" in 115 Arg.(value & flag & info ["json"] ~doc) 116 in 117 let limit_term = 118 let doc = "Maximum number of messages to fetch (default: all)" in 119 Arg.(value & opt (some int) None & info ["limit"; "n"] ~docv:"N" ~doc) 120 in 121 let run cfg json_output limit = 122 Eio_main.run @@ fun env -> 123 Eio.Switch.run @@ fun sw -> 124 let client = Jmap_eio.Cli.create_client ~sw env cfg in 125 let account_id = Jmap_eio.Cli.get_account_id cfg client in 126 127 Jmap_eio.Cli.debug cfg "Searching for Zulip notification emails"; 128 129 (* Build filter for emails from noreply@zulip.com *) 130 let cond : Jmap.Proto.Email.Filter_condition.t = { 131 in_mailbox = None; in_mailbox_other_than = None; 132 before = None; after = None; 133 min_size = None; max_size = None; 134 all_in_thread_have_keyword = None; 135 some_in_thread_have_keyword = None; 136 none_in_thread_have_keyword = None; 137 has_keyword = None; not_keyword = None; 138 has_attachment = None; 139 text = None; 140 from = Some "noreply@zulip.com"; 141 to_ = None; 142 cc = None; bcc = None; subject = None; 143 body = None; header = None; 144 } in 145 let filter = Jmap.Proto.Filter.Condition cond in 146 let sort = [Jmap.Proto.Filter.comparator ~is_ascending:false "receivedAt"] in 147 148 (* Query for all Zulip emails *) 149 let query_limit = match limit with 150 | Some n -> Int64.of_int n 151 | None -> Int64.of_int 10000 (* Large default to get "all" *) 152 in 153 let query_inv = Jmap_eio.Client.Build.email_query 154 ~call_id:"q1" 155 ~account_id 156 ~filter 157 ~sort 158 ~limit:query_limit 159 () 160 in 161 162 let req = Jmap_eio.Client.Build.( 163 make_request 164 ~capabilities:[Jmap.Proto.Capability.core; Jmap.Proto.Capability.mail] 165 [query_inv] 166 ) in 167 168 match Jmap_eio.Client.request client req with 169 | Error e -> 170 Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e); 171 exit 1 172 | Ok response -> 173 match Jmap_eio.Client.Parse.parse_email_query ~call_id:"q1" response with 174 | Error e -> 175 Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e); 176 exit 1 177 | Ok query_result -> 178 let email_ids = query_result.ids in 179 Jmap_eio.Cli.debug cfg "Found %d Zulip email IDs" (List.length email_ids); 180 181 if List.length email_ids = 0 then ( 182 if json_output then 183 Fmt.pr "[]@." 184 else 185 Fmt.pr "No Zulip notification emails found.@." 186 ) else ( 187 (* Fetch email details *) 188 let get_inv = Jmap_eio.Client.Build.email_get 189 ~call_id:"g1" 190 ~account_id 191 ~ids:email_ids 192 ~properties:["id"; "blobId"; "threadId"; "mailboxIds"; "keywords"; 193 "size"; "receivedAt"; "subject"; "from"] 194 () 195 in 196 let req2 = Jmap_eio.Client.Build.( 197 make_request 198 ~capabilities:[Jmap.Proto.Capability.core; Jmap.Proto.Capability.mail] 199 [get_inv] 200 ) in 201 202 match Jmap_eio.Client.request client req2 with 203 | Error e -> 204 Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e); 205 exit 1 206 | Ok response2 -> 207 match Jmap_eio.Client.Parse.parse_email_get ~call_id:"g1" response2 with 208 | Error e -> 209 Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e); 210 exit 1 211 | Ok get_result -> 212 (* Parse Zulip subjects and filter successful parses *) 213 let zulip_messages = 214 get_result.list 215 |> List.filter_map Zulip_message.of_email 216 in 217 218 Jmap_eio.Cli.debug cfg "Parsed %d Zulip messages from %d emails" 219 (List.length zulip_messages) 220 (List.length get_result.list); 221 222 if json_output then ( 223 (* Output as JSON *) 224 match Jsont_bytesrw.encode_string' ~format:Jsont.Indent Zulip_message.list_jsont zulip_messages with 225 | Ok json_str -> Fmt.pr "%s@." json_str 226 | Error e -> Fmt.epr "JSON encoding error: %s@." (Jsont.Error.to_string e) 227 ) else ( 228 (* Human-readable output *) 229 Fmt.pr "@[<v>%a (%d messages)@,@," 230 Fmt.(styled `Bold string) "Zulip Notifications" 231 (List.length zulip_messages); 232 233 (* Group by server, then by channel *) 234 let by_server = Hashtbl.create 8 in 235 List.iter (fun (msg : Zulip_message.t) -> 236 let existing = try Hashtbl.find by_server msg.server with Not_found -> [] in 237 Hashtbl.replace by_server msg.server (msg :: existing) 238 ) zulip_messages; 239 240 Hashtbl.iter (fun server msgs -> 241 Fmt.pr "%a [%s]@," 242 Fmt.(styled `Bold string) "Server:" 243 server; 244 245 (* Group by channel within server *) 246 let by_channel = Hashtbl.create 8 in 247 List.iter (fun (msg : Zulip_message.t) -> 248 let existing = try Hashtbl.find by_channel msg.channel with Not_found -> [] in 249 Hashtbl.replace by_channel msg.channel (msg :: existing) 250 ) msgs; 251 252 Hashtbl.iter (fun channel channel_msgs -> 253 Fmt.pr " %a #%s (%d)@," 254 Fmt.(styled `Cyan string) "Channel:" 255 channel 256 (List.length channel_msgs); 257 258 (* Sort by date descending *) 259 let sorted = List.sort (fun a b -> 260 Ptime.compare b.Zulip_message.date a.Zulip_message.date 261 ) channel_msgs in 262 263 List.iter (fun (msg : Zulip_message.t) -> 264 let read_marker = if msg.is_read then " " else "*" in 265 let labels_str = match msg.labels with 266 | [] -> "" 267 | ls -> " [" ^ String.concat ", " ls ^ "]" 268 in 269 Fmt.pr " %s %s %a %s%s@," 270 read_marker 271 (ptime_to_string msg.date) 272 Fmt.(styled `Yellow string) (truncate_string 40 msg.topic) 273 (truncate_string 12 msg.id) 274 labels_str 275 ) sorted; 276 Fmt.pr "@," 277 ) by_channel 278 ) by_server; 279 280 Fmt.pr "@]@." 281 ) 282 ) 283 in 284 let doc = "List Zulip notification emails with parsed channel/topic info" in 285 let man = [ 286 `S Manpage.s_description; 287 `P "Lists all emails from noreply@zulip.com and parses the subject line to extract \ 288 the Zulip channel, topic, and server name."; 289 `P "Subject format expected: \"#Channel > topic [Server Name]\""; 290 `S Manpage.s_examples; 291 `P "List all Zulip notifications:"; 292 `Pre " jmapq zulip-list"; 293 `P "Output as JSON:"; 294 `Pre " jmapq zulip-list --json"; 295 `P "Limit to 50 most recent:"; 296 `Pre " jmapq zulip-list -n 50"; 297 ] in 298 let info = Cmd.info "zulip-list" ~doc ~man in 299 Cmd.v info Term.(const run $ Jmap_eio.Cli.config_term $ json_term $ limit_term) 300 301(** {1 Zulip Timeout Command} *) 302 303(** The keyword used to mark Zulip notifications as processed *) 304let zulip_processed_keyword = "zulip-processed" 305 306let zulip_timeout_cmd = 307 let email_ids_term = 308 let doc = "Email IDs to mark as processed" in 309 Arg.(non_empty & pos_all string [] & info [] ~docv:"EMAIL_ID" ~doc) 310 in 311 let verbose_term = 312 let doc = "Show the raw JMAP server response" in 313 Arg.(value & flag & info ["v"; "verbose"] ~doc) 314 in 315 let run cfg verbose email_id_strs = 316 Eio_main.run @@ fun env -> 317 Eio.Switch.run @@ fun sw -> 318 let client = Jmap_eio.Cli.create_client ~sw env cfg in 319 let account_id = Jmap_eio.Cli.get_account_id cfg client in 320 let email_ids = List.map Jmap.Proto.Id.of_string_exn email_id_strs in 321 322 Jmap_eio.Cli.debug cfg "Marking %d email(s) with '%s' keyword" 323 (List.length email_ids) zulip_processed_keyword; 324 325 (* Build patch to add the zulip-processed keyword and mark as read *) 326 let patch = 327 let open Jmap_eio.Chain in 328 json_obj [ 329 ("keywords/" ^ zulip_processed_keyword, json_bool true); 330 ("keywords/$seen", json_bool true); 331 ] 332 in 333 334 (* Build updates list: each email ID gets the same patch *) 335 let updates = List.map (fun id -> (id, patch)) email_ids in 336 337 let open Jmap_eio.Chain in 338 let request, set_h = build 339 ~capabilities:[Jmap.Proto.Capability.core; Jmap.Proto.Capability.mail] 340 begin 341 email_set ~account_id 342 ~update:updates 343 () 344 end in 345 346 match Jmap_eio.Client.request client request with 347 | Error e -> 348 Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e); 349 exit 1 350 | Ok response -> 351 (* Print raw response if verbose *) 352 if verbose then begin 353 Fmt.pr "@[<v>%a:@," Fmt.(styled `Bold string) "Server Response"; 354 (match Jsont_bytesrw.encode_string' ~format:Jsont.Indent 355 Jmap.Proto.Response.jsont response with 356 | Ok json_str -> Fmt.pr "%s@,@]@." json_str 357 | Error e -> Fmt.epr "JSON encoding error: %s@." (Jsont.Error.to_string e)) 358 end; 359 (* Check for JMAP method-level errors first *) 360 let call_id = Jmap_eio.Chain.call_id set_h in 361 (match Jmap.Proto.Response.find_response call_id response with 362 | None -> 363 Fmt.epr "Error: No response found for call_id %s@." call_id; 364 exit 1 365 | Some inv when Jmap.Proto.Response.is_error inv -> 366 (match Jmap.Proto.Response.get_error inv with 367 | Some err -> 368 Fmt.epr "JMAP Error: %s%s@." 369 (Jmap.Proto.Error.method_error_type_to_string err.type_) 370 (match err.description with Some d -> " - " ^ d | None -> ""); 371 exit 1 372 | None -> 373 Fmt.epr "JMAP Error: Unknown error@."; 374 exit 1) 375 | Some _ -> 376 match parse set_h response with 377 | Error e -> 378 Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e); 379 exit 1 380 | Ok result -> 381 (* Report successes *) 382 let updated_ids = 383 result.updated 384 |> Option.value ~default:[] 385 |> List.map (fun (id, _) -> Jmap.Proto.Id.to_string id) 386 in 387 if List.length updated_ids > 0 then begin 388 Fmt.pr "@[<v>%a %d email(s) as read with '%s':@," 389 Fmt.(styled `Green string) "Marked" 390 (List.length updated_ids) 391 zulip_processed_keyword; 392 List.iter (fun id -> Fmt.pr " %s@," id) updated_ids; 393 Fmt.pr "@]@." 394 end; 395 396 (* Report failures *) 397 let not_updated = Option.value ~default:[] result.not_updated in 398 if not_updated <> [] then begin 399 Fmt.epr "@[<v>%a to mark %d email(s):@," 400 Fmt.(styled `Red string) "Failed" 401 (List.length not_updated); 402 List.iter (fun (id, err) -> 403 let open Jmap.Proto.Error in 404 let err_type = set_error_type_to_string err.type_ in 405 let err_desc = Option.value ~default:"" err.description in 406 Fmt.epr " %s: %s%s@," 407 (Jmap.Proto.Id.to_string id) 408 err_type 409 (if err_desc = "" then "" else " - " ^ err_desc) 410 ) not_updated; 411 Fmt.epr "@]@."; 412 exit 1 413 end) 414 in 415 let doc = "Mark Zulip notification emails as processed" in 416 let man = [ 417 `S Manpage.s_description; 418 `P (Printf.sprintf "Adds the '%s' keyword to the specified email(s). \ 419 This keyword can be used to filter processed Zulip notifications \ 420 or set up server-side rules to auto-archive them." 421 zulip_processed_keyword); 422 `S Manpage.s_examples; 423 `P "Mark a single email as processed:"; 424 `Pre " jmapq zulip-timeout StrrDTS_WEa3"; 425 `P "Mark multiple emails as processed:"; 426 `Pre " jmapq zulip-timeout StrrDTS_WEa3 StrsGZ7P8Dpc StrsGuCSXJ3Z"; 427 ] in 428 let info = Cmd.info "zulip-timeout" ~doc ~man in 429 Cmd.v info Term.(const run $ Jmap_eio.Cli.config_term $ verbose_term $ email_ids_term) 430 431(** {1 Zulip View Command} *) 432 433let zulip_view_cmd = 434 let json_term = 435 let doc = "Output as JSON" in 436 Arg.(value & flag & info ["json"] ~doc) 437 in 438 let limit_term = 439 let doc = "Maximum number of messages to fetch (default: all)" in 440 Arg.(value & opt (some int) None & info ["limit"; "n"] ~docv:"N" ~doc) 441 in 442 let verbose_term = 443 let doc = "Show the raw JMAP request and response" in 444 Arg.(value & flag & info ["v"; "verbose"] ~doc) 445 in 446 let run cfg json_output limit verbose = 447 Eio_main.run @@ fun env -> 448 Eio.Switch.run @@ fun sw -> 449 let client = Jmap_eio.Cli.create_client ~sw env cfg in 450 let account_id = Jmap_eio.Cli.get_account_id cfg client in 451 452 Jmap_eio.Cli.debug cfg "Searching for Zulip emails marked as processed"; 453 454 (* Build filter for emails from noreply@zulip.com with zulip-processed keyword *) 455 let cond : Jmap.Proto.Email.Filter_condition.t = { 456 in_mailbox = None; in_mailbox_other_than = None; 457 before = None; after = None; 458 min_size = None; max_size = None; 459 all_in_thread_have_keyword = None; 460 some_in_thread_have_keyword = None; 461 none_in_thread_have_keyword = None; 462 has_keyword = Some zulip_processed_keyword; 463 not_keyword = None; 464 has_attachment = None; 465 text = None; 466 from = Some "noreply@zulip.com"; 467 to_ = None; 468 cc = None; bcc = None; subject = None; 469 body = None; header = None; 470 } in 471 let filter = Jmap.Proto.Filter.Condition cond in 472 let sort = [Jmap.Proto.Filter.comparator ~is_ascending:false "receivedAt"] in 473 474 (* Query for processed Zulip emails *) 475 let query_limit = match limit with 476 | Some n -> Int64.of_int n 477 | None -> Int64.of_int 10000 478 in 479 let query_inv = Jmap_eio.Client.Build.email_query 480 ~call_id:"q1" 481 ~account_id 482 ~filter 483 ~sort 484 ~limit:query_limit 485 () 486 in 487 488 let req = Jmap_eio.Client.Build.( 489 make_request 490 ~capabilities:[Jmap.Proto.Capability.core; Jmap.Proto.Capability.mail] 491 [query_inv] 492 ) in 493 494 (* Print request if verbose *) 495 if verbose then begin 496 Fmt.pr "@[<v>%a:@," Fmt.(styled `Bold string) "Request"; 497 (match Jsont_bytesrw.encode_string' ~format:Jsont.Indent 498 Jmap.Proto.Request.jsont req with 499 | Ok json_str -> Fmt.pr "%s@,@]@." json_str 500 | Error e -> Fmt.epr "JSON encoding error: %s@." (Jsont.Error.to_string e)) 501 end; 502 503 match Jmap_eio.Client.request client req with 504 | Error e -> 505 Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e); 506 exit 1 507 | Ok response -> 508 (* Print response if verbose *) 509 if verbose then begin 510 Fmt.pr "@[<v>%a:@," Fmt.(styled `Bold string) "Response"; 511 (match Jsont_bytesrw.encode_string' ~format:Jsont.Indent 512 Jmap.Proto.Response.jsont response with 513 | Ok json_str -> Fmt.pr "%s@,@]@." json_str 514 | Error e -> Fmt.epr "JSON encoding error: %s@." (Jsont.Error.to_string e)) 515 end; 516 match Jmap_eio.Client.Parse.parse_email_query ~call_id:"q1" response with 517 | Error e -> 518 Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e); 519 exit 1 520 | Ok query_result -> 521 let email_ids = query_result.ids in 522 Jmap_eio.Cli.debug cfg "Found %d processed Zulip email IDs" (List.length email_ids); 523 524 if List.length email_ids = 0 then ( 525 if json_output then 526 Fmt.pr "[]@." 527 else 528 Fmt.pr "No Zulip emails marked as processed.@." 529 ) else ( 530 (* Fetch email details *) 531 let get_inv = Jmap_eio.Client.Build.email_get 532 ~call_id:"g1" 533 ~account_id 534 ~ids:email_ids 535 ~properties:["id"; "blobId"; "threadId"; "mailboxIds"; "keywords"; 536 "size"; "receivedAt"; "subject"; "from"] 537 () 538 in 539 let req2 = Jmap_eio.Client.Build.( 540 make_request 541 ~capabilities:[Jmap.Proto.Capability.core; Jmap.Proto.Capability.mail] 542 [get_inv] 543 ) in 544 545 match Jmap_eio.Client.request client req2 with 546 | Error e -> 547 Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e); 548 exit 1 549 | Ok response2 -> 550 match Jmap_eio.Client.Parse.parse_email_get ~call_id:"g1" response2 with 551 | Error e -> 552 Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e); 553 exit 1 554 | Ok get_result -> 555 (* Parse Zulip subjects and filter successful parses *) 556 let zulip_messages = 557 get_result.list 558 |> List.filter_map Zulip_message.of_email 559 in 560 561 Jmap_eio.Cli.debug cfg "Parsed %d Zulip messages from %d emails" 562 (List.length zulip_messages) 563 (List.length get_result.list); 564 565 if json_output then ( 566 (* Output as JSON *) 567 match Jsont_bytesrw.encode_string' ~format:Jsont.Indent Zulip_message.list_jsont zulip_messages with 568 | Ok json_str -> Fmt.pr "%s@." json_str 569 | Error e -> Fmt.epr "JSON encoding error: %s@." (Jsont.Error.to_string e) 570 ) else ( 571 (* Human-readable output *) 572 Fmt.pr "@[<v>%a (%d messages)@,@," 573 Fmt.(styled `Bold string) "Processed Zulip Notifications" 574 (List.length zulip_messages); 575 576 (* Group by server, then by channel *) 577 let by_server = Hashtbl.create 8 in 578 List.iter (fun (msg : Zulip_message.t) -> 579 let existing = try Hashtbl.find by_server msg.server with Not_found -> [] in 580 Hashtbl.replace by_server msg.server (msg :: existing) 581 ) zulip_messages; 582 583 Hashtbl.iter (fun server msgs -> 584 Fmt.pr "%a [%s]@," 585 Fmt.(styled `Bold string) "Server:" 586 server; 587 588 (* Group by channel within server *) 589 let by_channel = Hashtbl.create 8 in 590 List.iter (fun (msg : Zulip_message.t) -> 591 let existing = try Hashtbl.find by_channel msg.channel with Not_found -> [] in 592 Hashtbl.replace by_channel msg.channel (msg :: existing) 593 ) msgs; 594 595 Hashtbl.iter (fun channel channel_msgs -> 596 Fmt.pr " %a #%s (%d)@," 597 Fmt.(styled `Cyan string) "Channel:" 598 channel 599 (List.length channel_msgs); 600 601 (* Sort by date descending *) 602 let sorted = List.sort (fun a b -> 603 Ptime.compare b.Zulip_message.date a.Zulip_message.date 604 ) channel_msgs in 605 606 List.iter (fun (msg : Zulip_message.t) -> 607 let read_marker = if msg.is_read then " " else "*" in 608 let labels_str = match msg.labels with 609 | [] -> "" 610 | ls -> " [" ^ String.concat ", " ls ^ "]" 611 in 612 Fmt.pr " %s %s %a %s%s@," 613 read_marker 614 (ptime_to_string msg.date) 615 Fmt.(styled `Yellow string) (truncate_string 40 msg.topic) 616 (truncate_string 12 msg.id) 617 labels_str 618 ) sorted; 619 Fmt.pr "@," 620 ) by_channel 621 ) by_server; 622 623 Fmt.pr "@]@." 624 ) 625 ) 626 in 627 let doc = "List Zulip emails that have been marked as processed" in 628 let man = [ 629 `S Manpage.s_description; 630 `P (Printf.sprintf "Lists all Zulip notification emails that have the '%s' keyword." 631 zulip_processed_keyword); 632 `S Manpage.s_examples; 633 `P "List all processed Zulip notifications:"; 634 `Pre " jmapq zulip-view"; 635 `P "Output as JSON:"; 636 `Pre " jmapq zulip-view --json"; 637 `P "Limit to 50 most recent:"; 638 `Pre " jmapq zulip-view -n 50"; 639 ] in 640 let info = Cmd.info "zulip-view" ~doc ~man in 641 Cmd.v info Term.(const run $ Jmap_eio.Cli.config_term $ json_term $ limit_term $ verbose_term) 642 643(** {1 Main Command Group} *) 644 645let main_cmd = 646 let doc = "JMAPQ - Specialist JMAP workflow commands" in 647 let man = [ 648 `S Manpage.s_description; 649 `P "A collection of specialist workflow commands for JMAP email processing."; 650 `S Manpage.s_environment; 651 `P Jmap_eio.Cli.env_docs; 652 ] in 653 let info = Cmd.info "jmapq" ~version:"0.1.0" ~doc ~man in 654 Cmd.group info [ 655 zulip_list_cmd; 656 zulip_timeout_cmd; 657 zulip_view_cmd; 658 ] 659 660let () = 661 Fmt_tty.setup_std_outputs (); 662 exit (Cmd.eval main_cmd)