···11+ISC License
22+33+Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>
44+55+Permission to use, copy, modify, and distribute this software for any
66+purpose with or without fee is hereby granted, provided that the above
77+copyright notice and this permission notice appear in all copies.
88+99+THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
1010+WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
1111+MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
1212+ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
1313+WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
1414+ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
1515+OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+54
README.md
···11+# ocaml-jmap - JMAP Protocol Implementation for OCaml
22+33+A complete implementation of the JSON Meta Application Protocol (JMAP) as specified in RFC 8620 (core) and RFC 8621 (mail).
44+55+## Packages
66+77+- **jmap** - Core JMAP protocol types and serialization
88+- **jmap-eio** - JMAP client using Eio for async I/O
99+- **jmap-brr** - JMAP client for browsers using js_of_ocaml
1010+1111+## Key Features
1212+1313+- Full RFC 8620 (JMAP Core) support: sessions, accounts, method calls, and error handling
1414+- Full RFC 8621 (JMAP Mail) support: mailboxes, emails, threads, identities, and submissions
1515+- Type-safe API with comprehensive type definitions
1616+- Multiple backends: Eio for native async, Brr for browser-based clients
1717+- JSON serialization via jsont
1818+1919+## Usage
2020+2121+```ocaml
2222+(* Query emails from a mailbox *)
2323+open Jmap
2424+2525+let query_emails ~client ~account_id ~mailbox_id =
2626+ let filter = Email.Query.Filter.(in_mailbox mailbox_id) in
2727+ let query = Email.Query.make ~account_id ~filter () in
2828+ Client.call client query
2929+```
3030+3131+## Installation
3232+3333+```
3434+opam install jmap jmap-eio
3535+```
3636+3737+For browser-based applications:
3838+3939+```
4040+opam install jmap jmap-brr
4141+```
4242+4343+## Documentation
4444+4545+API documentation is available via:
4646+4747+```
4848+opam install jmap
4949+odig doc jmap
5050+```
5151+5252+## License
5353+5454+ISC
+233-5
bin/jmapq.ml
···308308 let doc = "Email IDs to mark as processed" in
309309 Arg.(non_empty & pos_all string [] & info [] ~docv:"EMAIL_ID" ~doc)
310310 in
311311- let run cfg email_id_strs =
311311+ let verbose_term =
312312+ let doc = "Show the raw JMAP server response" in
313313+ Arg.(value & flag & info ["v"; "verbose"] ~doc)
314314+ in
315315+ let run cfg verbose email_id_strs =
312316 Eio_main.run @@ fun env ->
313317 Eio.Switch.run @@ fun sw ->
314318 let client = Jmap_eio.Cli.create_client ~sw env cfg in
···318322 Jmap_eio.Cli.debug cfg "Marking %d email(s) with '%s' keyword"
319323 (List.length email_ids) zulip_processed_keyword;
320324321321- (* Build patch to add the zulip-processed keyword *)
325325+ (* Build patch to add the zulip-processed keyword and mark as read *)
322326 let patch =
323327 let open Jmap_eio.Chain in
324324- json_obj [("keywords/" ^ zulip_processed_keyword, json_bool true)]
328328+ json_obj [
329329+ ("keywords/" ^ zulip_processed_keyword, json_bool true);
330330+ ("keywords/$seen", json_bool true);
331331+ ]
325332 in
326333327334 (* Build updates list: each email ID gets the same patch *)
···341348 Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e);
342349 exit 1
343350 | Ok response ->
351351+ (* Print raw response if verbose *)
352352+ if verbose then begin
353353+ Fmt.pr "@[<v>%a:@," Fmt.(styled `Bold string) "Server Response";
354354+ (match Jsont_bytesrw.encode_string' ~format:Jsont.Indent
355355+ Jmap.Proto.Response.jsont response with
356356+ | Ok json_str -> Fmt.pr "%s@,@]@." json_str
357357+ | Error e -> Fmt.epr "JSON encoding error: %s@." (Jsont.Error.to_string e))
358358+ end;
344359 (* Check for JMAP method-level errors first *)
345360 let call_id = Jmap_eio.Chain.call_id set_h in
346361 (match Jmap.Proto.Response.find_response call_id response with
···370385 |> List.map (fun (id, _) -> Jmap.Proto.Id.to_string id)
371386 in
372387 if List.length updated_ids > 0 then begin
373373- Fmt.pr "@[<v>%a %d email(s) with '%s':@,"
388388+ Fmt.pr "@[<v>%a %d email(s) as read with '%s':@,"
374389 Fmt.(styled `Green string) "Marked"
375390 (List.length updated_ids)
376391 zulip_processed_keyword;
···411426 `Pre " jmapq zulip-timeout StrrDTS_WEa3 StrsGZ7P8Dpc StrsGuCSXJ3Z";
412427 ] in
413428 let info = Cmd.info "zulip-timeout" ~doc ~man in
414414- Cmd.v info Term.(const run $ Jmap_eio.Cli.config_term $ email_ids_term)
429429+ Cmd.v info Term.(const run $ Jmap_eio.Cli.config_term $ verbose_term $ email_ids_term)
430430+431431+(** {1 Zulip View Command} *)
432432+433433+let zulip_view_cmd =
434434+ let json_term =
435435+ let doc = "Output as JSON" in
436436+ Arg.(value & flag & info ["json"] ~doc)
437437+ in
438438+ let limit_term =
439439+ let doc = "Maximum number of messages to fetch (default: all)" in
440440+ Arg.(value & opt (some int) None & info ["limit"; "n"] ~docv:"N" ~doc)
441441+ in
442442+ let verbose_term =
443443+ let doc = "Show the raw JMAP request and response" in
444444+ Arg.(value & flag & info ["v"; "verbose"] ~doc)
445445+ in
446446+ let run cfg json_output limit verbose =
447447+ Eio_main.run @@ fun env ->
448448+ Eio.Switch.run @@ fun sw ->
449449+ let client = Jmap_eio.Cli.create_client ~sw env cfg in
450450+ let account_id = Jmap_eio.Cli.get_account_id cfg client in
451451+452452+ Jmap_eio.Cli.debug cfg "Searching for Zulip emails marked as processed";
453453+454454+ (* Build filter for emails from noreply@zulip.com with zulip-processed keyword *)
455455+ let cond : Jmap.Proto.Email.Filter_condition.t = {
456456+ in_mailbox = None; in_mailbox_other_than = None;
457457+ before = None; after = None;
458458+ min_size = None; max_size = None;
459459+ all_in_thread_have_keyword = None;
460460+ some_in_thread_have_keyword = None;
461461+ none_in_thread_have_keyword = None;
462462+ has_keyword = Some zulip_processed_keyword;
463463+ not_keyword = None;
464464+ has_attachment = None;
465465+ text = None;
466466+ from = Some "noreply@zulip.com";
467467+ to_ = None;
468468+ cc = None; bcc = None; subject = None;
469469+ body = None; header = None;
470470+ } in
471471+ let filter = Jmap.Proto.Filter.Condition cond in
472472+ let sort = [Jmap.Proto.Filter.comparator ~is_ascending:false "receivedAt"] in
473473+474474+ (* Query for processed Zulip emails *)
475475+ let query_limit = match limit with
476476+ | Some n -> Int64.of_int n
477477+ | None -> Int64.of_int 10000
478478+ in
479479+ let query_inv = Jmap_eio.Client.Build.email_query
480480+ ~call_id:"q1"
481481+ ~account_id
482482+ ~filter
483483+ ~sort
484484+ ~limit:query_limit
485485+ ()
486486+ in
487487+488488+ let req = Jmap_eio.Client.Build.(
489489+ make_request
490490+ ~capabilities:[Jmap.Proto.Capability.core; Jmap.Proto.Capability.mail]
491491+ [query_inv]
492492+ ) in
493493+494494+ (* Print request if verbose *)
495495+ if verbose then begin
496496+ Fmt.pr "@[<v>%a:@," Fmt.(styled `Bold string) "Request";
497497+ (match Jsont_bytesrw.encode_string' ~format:Jsont.Indent
498498+ Jmap.Proto.Request.jsont req with
499499+ | Ok json_str -> Fmt.pr "%s@,@]@." json_str
500500+ | Error e -> Fmt.epr "JSON encoding error: %s@." (Jsont.Error.to_string e))
501501+ end;
502502+503503+ match Jmap_eio.Client.request client req with
504504+ | Error e ->
505505+ Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e);
506506+ exit 1
507507+ | Ok response ->
508508+ (* Print response if verbose *)
509509+ if verbose then begin
510510+ Fmt.pr "@[<v>%a:@," Fmt.(styled `Bold string) "Response";
511511+ (match Jsont_bytesrw.encode_string' ~format:Jsont.Indent
512512+ Jmap.Proto.Response.jsont response with
513513+ | Ok json_str -> Fmt.pr "%s@,@]@." json_str
514514+ | Error e -> Fmt.epr "JSON encoding error: %s@." (Jsont.Error.to_string e))
515515+ end;
516516+ match Jmap_eio.Client.Parse.parse_email_query ~call_id:"q1" response with
517517+ | Error e ->
518518+ Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e);
519519+ exit 1
520520+ | Ok query_result ->
521521+ let email_ids = query_result.ids in
522522+ Jmap_eio.Cli.debug cfg "Found %d processed Zulip email IDs" (List.length email_ids);
523523+524524+ if List.length email_ids = 0 then (
525525+ if json_output then
526526+ Fmt.pr "[]@."
527527+ else
528528+ Fmt.pr "No Zulip emails marked as processed.@."
529529+ ) else (
530530+ (* Fetch email details *)
531531+ let get_inv = Jmap_eio.Client.Build.email_get
532532+ ~call_id:"g1"
533533+ ~account_id
534534+ ~ids:email_ids
535535+ ~properties:["id"; "blobId"; "threadId"; "mailboxIds"; "keywords";
536536+ "size"; "receivedAt"; "subject"; "from"]
537537+ ()
538538+ in
539539+ let req2 = Jmap_eio.Client.Build.(
540540+ make_request
541541+ ~capabilities:[Jmap.Proto.Capability.core; Jmap.Proto.Capability.mail]
542542+ [get_inv]
543543+ ) in
544544+545545+ match Jmap_eio.Client.request client req2 with
546546+ | Error e ->
547547+ Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e);
548548+ exit 1
549549+ | Ok response2 ->
550550+ match Jmap_eio.Client.Parse.parse_email_get ~call_id:"g1" response2 with
551551+ | Error e ->
552552+ Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e);
553553+ exit 1
554554+ | Ok get_result ->
555555+ (* Parse Zulip subjects and filter successful parses *)
556556+ let zulip_messages =
557557+ get_result.list
558558+ |> List.filter_map Zulip_message.of_email
559559+ in
560560+561561+ Jmap_eio.Cli.debug cfg "Parsed %d Zulip messages from %d emails"
562562+ (List.length zulip_messages)
563563+ (List.length get_result.list);
564564+565565+ if json_output then (
566566+ (* Output as JSON *)
567567+ match Jsont_bytesrw.encode_string' ~format:Jsont.Indent Zulip_message.list_jsont zulip_messages with
568568+ | Ok json_str -> Fmt.pr "%s@." json_str
569569+ | Error e -> Fmt.epr "JSON encoding error: %s@." (Jsont.Error.to_string e)
570570+ ) else (
571571+ (* Human-readable output *)
572572+ Fmt.pr "@[<v>%a (%d messages)@,@,"
573573+ Fmt.(styled `Bold string) "Processed Zulip Notifications"
574574+ (List.length zulip_messages);
575575+576576+ (* Group by server, then by channel *)
577577+ let by_server = Hashtbl.create 8 in
578578+ List.iter (fun (msg : Zulip_message.t) ->
579579+ let existing = try Hashtbl.find by_server msg.server with Not_found -> [] in
580580+ Hashtbl.replace by_server msg.server (msg :: existing)
581581+ ) zulip_messages;
582582+583583+ Hashtbl.iter (fun server msgs ->
584584+ Fmt.pr "%a [%s]@,"
585585+ Fmt.(styled `Bold string) "Server:"
586586+ server;
587587+588588+ (* Group by channel within server *)
589589+ let by_channel = Hashtbl.create 8 in
590590+ List.iter (fun (msg : Zulip_message.t) ->
591591+ let existing = try Hashtbl.find by_channel msg.channel with Not_found -> [] in
592592+ Hashtbl.replace by_channel msg.channel (msg :: existing)
593593+ ) msgs;
594594+595595+ Hashtbl.iter (fun channel channel_msgs ->
596596+ Fmt.pr " %a #%s (%d)@,"
597597+ Fmt.(styled `Cyan string) "Channel:"
598598+ channel
599599+ (List.length channel_msgs);
600600+601601+ (* Sort by date descending *)
602602+ let sorted = List.sort (fun a b ->
603603+ Ptime.compare b.Zulip_message.date a.Zulip_message.date
604604+ ) channel_msgs in
605605+606606+ List.iter (fun (msg : Zulip_message.t) ->
607607+ let read_marker = if msg.is_read then " " else "*" in
608608+ let labels_str = match msg.labels with
609609+ | [] -> ""
610610+ | ls -> " [" ^ String.concat ", " ls ^ "]"
611611+ in
612612+ Fmt.pr " %s %s %a %s%s@,"
613613+ read_marker
614614+ (ptime_to_string msg.date)
615615+ Fmt.(styled `Yellow string) (truncate_string 40 msg.topic)
616616+ (truncate_string 12 msg.id)
617617+ labels_str
618618+ ) sorted;
619619+ Fmt.pr "@,"
620620+ ) by_channel
621621+ ) by_server;
622622+623623+ Fmt.pr "@]@."
624624+ )
625625+ )
626626+ in
627627+ let doc = "List Zulip emails that have been marked as processed" in
628628+ let man = [
629629+ `S Manpage.s_description;
630630+ `P (Printf.sprintf "Lists all Zulip notification emails that have the '%s' keyword."
631631+ zulip_processed_keyword);
632632+ `S Manpage.s_examples;
633633+ `P "List all processed Zulip notifications:";
634634+ `Pre " jmapq zulip-view";
635635+ `P "Output as JSON:";
636636+ `Pre " jmapq zulip-view --json";
637637+ `P "Limit to 50 most recent:";
638638+ `Pre " jmapq zulip-view -n 50";
639639+ ] in
640640+ let info = Cmd.info "zulip-view" ~doc ~man in
641641+ Cmd.v info Term.(const run $ Jmap_eio.Cli.config_term $ json_term $ limit_term $ verbose_term)
415642416643(** {1 Main Command Group} *)
417644···427654 Cmd.group info [
428655 zulip_list_cmd;
429656 zulip_timeout_cmd;
657657+ zulip_view_cmd;
430658 ]
431659432660let () =