this repo has no description
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)