this repo has no description

metadata + protocol fixes

+20 -1
.gitignore
··· 1 - _build 1 + # OCaml build artifacts 2 + _build/ 3 + *.install 4 + *.merlin 5 + 6 + # Third-party sources (fetch locally with opam source) 7 + third_party/ 8 + 9 + # Editor and OS files 10 + .DS_Store 11 + *.swp 12 + *~ 13 + .vscode/ 14 + .idea/ 15 + 16 + # Opam local switch 17 + _opam/ 18 + 19 + # Environment and secrets 2 20 .env 3 21 .api-key 22 + .api-key-rw 4 23 .api-url
+1
.ocamlformat
··· 1 + version=0.28.1
+53
.tangled/workflows/build.yml
··· 1 + when: 2 + - event: ["push", "pull_request"] 3 + branch: ["main"] 4 + 5 + engine: nixery 6 + 7 + dependencies: 8 + nixpkgs: 9 + - shell 10 + - stdenv 11 + - findutils 12 + - binutils 13 + - libunwind 14 + - ncurses 15 + - opam 16 + - git 17 + - gawk 18 + - gnupatch 19 + - gnum4 20 + - gnumake 21 + - gnutar 22 + - gnused 23 + - gnugrep 24 + - diffutils 25 + - gzip 26 + - bzip2 27 + - gcc 28 + - ocaml 29 + - pkg-config 30 + 31 + steps: 32 + - name: opam 33 + command: | 34 + opam init --disable-sandboxing -a -y 35 + - name: repo 36 + command: | 37 + opam repo add aoah https://tangled.org/anil.recoil.org/aoah-opam-repo.git 38 + - name: switch 39 + command: | 40 + opam install . --confirm-level=unsafe-yes --deps-only 41 + - name: build 42 + command: | 43 + opam exec -- dune build -p jmap 44 + - name: switch-test 45 + command: | 46 + opam install . --confirm-level=unsafe-yes --deps-only --with-test 47 + - name: test 48 + command: | 49 + opam exec -- dune runtest --verbose 50 + - name: doc 51 + command: | 52 + opam install -y odoc 53 + opam exec -- dune build @doc
+15
LICENSE.md
··· 1 + ISC License 2 + 3 + Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org> 4 + 5 + Permission to use, copy, modify, and distribute this software for any 6 + purpose with or without fee is hereby granted, provided that the above 7 + copyright notice and this permission notice appear in all copies. 8 + 9 + THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES 10 + WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF 11 + MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR 12 + ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES 13 + WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN 14 + ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF 15 + OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+54
README.md
··· 1 + # ocaml-jmap - JMAP Protocol Implementation for OCaml 2 + 3 + A complete implementation of the JSON Meta Application Protocol (JMAP) as specified in RFC 8620 (core) and RFC 8621 (mail). 4 + 5 + ## Packages 6 + 7 + - **jmap** - Core JMAP protocol types and serialization 8 + - **jmap-eio** - JMAP client using Eio for async I/O 9 + - **jmap-brr** - JMAP client for browsers using js_of_ocaml 10 + 11 + ## Key Features 12 + 13 + - Full RFC 8620 (JMAP Core) support: sessions, accounts, method calls, and error handling 14 + - Full RFC 8621 (JMAP Mail) support: mailboxes, emails, threads, identities, and submissions 15 + - Type-safe API with comprehensive type definitions 16 + - Multiple backends: Eio for native async, Brr for browser-based clients 17 + - JSON serialization via jsont 18 + 19 + ## Usage 20 + 21 + ```ocaml 22 + (* Query emails from a mailbox *) 23 + open Jmap 24 + 25 + let query_emails ~client ~account_id ~mailbox_id = 26 + let filter = Email.Query.Filter.(in_mailbox mailbox_id) in 27 + let query = Email.Query.make ~account_id ~filter () in 28 + Client.call client query 29 + ``` 30 + 31 + ## Installation 32 + 33 + ``` 34 + opam install jmap jmap-eio 35 + ``` 36 + 37 + For browser-based applications: 38 + 39 + ``` 40 + opam install jmap jmap-brr 41 + ``` 42 + 43 + ## Documentation 44 + 45 + API documentation is available via: 46 + 47 + ``` 48 + opam install jmap 49 + odig doc jmap 50 + ``` 51 + 52 + ## License 53 + 54 + ISC
+233 -5
bin/jmapq.ml
··· 308 308 let doc = "Email IDs to mark as processed" in 309 309 Arg.(non_empty & pos_all string [] & info [] ~docv:"EMAIL_ID" ~doc) 310 310 in 311 - let run cfg email_id_strs = 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 = 312 316 Eio_main.run @@ fun env -> 313 317 Eio.Switch.run @@ fun sw -> 314 318 let client = Jmap_eio.Cli.create_client ~sw env cfg in ··· 318 322 Jmap_eio.Cli.debug cfg "Marking %d email(s) with '%s' keyword" 319 323 (List.length email_ids) zulip_processed_keyword; 320 324 321 - (* Build patch to add the zulip-processed keyword *) 325 + (* Build patch to add the zulip-processed keyword and mark as read *) 322 326 let patch = 323 327 let open Jmap_eio.Chain in 324 - json_obj [("keywords/" ^ zulip_processed_keyword, json_bool true)] 328 + json_obj [ 329 + ("keywords/" ^ zulip_processed_keyword, json_bool true); 330 + ("keywords/$seen", json_bool true); 331 + ] 325 332 in 326 333 327 334 (* Build updates list: each email ID gets the same patch *) ··· 341 348 Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e); 342 349 exit 1 343 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; 344 359 (* Check for JMAP method-level errors first *) 345 360 let call_id = Jmap_eio.Chain.call_id set_h in 346 361 (match Jmap.Proto.Response.find_response call_id response with ··· 370 385 |> List.map (fun (id, _) -> Jmap.Proto.Id.to_string id) 371 386 in 372 387 if List.length updated_ids > 0 then begin 373 - Fmt.pr "@[<v>%a %d email(s) with '%s':@," 388 + Fmt.pr "@[<v>%a %d email(s) as read with '%s':@," 374 389 Fmt.(styled `Green string) "Marked" 375 390 (List.length updated_ids) 376 391 zulip_processed_keyword; ··· 411 426 `Pre " jmapq zulip-timeout StrrDTS_WEa3 StrsGZ7P8Dpc StrsGuCSXJ3Z"; 412 427 ] in 413 428 let info = Cmd.info "zulip-timeout" ~doc ~man in 414 - Cmd.v info Term.(const run $ Jmap_eio.Cli.config_term $ email_ids_term) 429 + Cmd.v info Term.(const run $ Jmap_eio.Cli.config_term $ verbose_term $ email_ids_term) 430 + 431 + (** {1 Zulip View Command} *) 432 + 433 + let 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) 415 642 416 643 (** {1 Main Command Group} *) 417 644 ··· 427 654 Cmd.group info [ 428 655 zulip_list_cmd; 429 656 zulip_timeout_cmd; 657 + zulip_view_cmd; 430 658 ] 431 659 432 660 let () =
+5 -4
dune-project
··· 6 6 7 7 (generate_opam_files true) 8 8 9 - (source 10 - (github avsm/ocaml-jmap)) 9 + (license ISC) 11 10 12 11 (authors "Anil Madhavapeddy <anil@recoil.org>") 13 12 14 13 (maintainers "Anil Madhavapeddy <anil@recoil.org>") 15 14 16 - (license ISC) 15 + (homepage "https://tangled.org/@anil.recoil.org/ocaml-jmap") 16 + 17 + (bug_reports "https://tangled.org/@anil.recoil.org/ocaml-jmap/issues") 17 18 18 - (documentation https://avsm.github.io/ocaml-jmap) 19 + (maintenance_intent "(latest)") 19 20 20 21 (package 21 22 (name jmap)
+2 -4
jmap-brr.opam
··· 6 6 maintainer: ["Anil Madhavapeddy <anil@recoil.org>"] 7 7 authors: ["Anil Madhavapeddy <anil@recoil.org>"] 8 8 license: "ISC" 9 - homepage: "https://github.com/avsm/ocaml-jmap" 10 - doc: "https://avsm.github.io/ocaml-jmap" 11 - bug-reports: "https://github.com/avsm/ocaml-jmap/issues" 9 + homepage: "https://tangled.org/@anil.recoil.org/ocaml-jmap" 10 + bug-reports: "https://tangled.org/@anil.recoil.org/ocaml-jmap/issues" 12 11 depends: [ 13 12 "dune" {>= "3.20"} 14 13 "ocaml" {>= "5.4.0"} ··· 31 30 "@doc" {with-doc} 32 31 ] 33 32 ] 34 - dev-repo: "git+https://github.com/avsm/ocaml-jmap.git" 35 33 x-maintenance-intent: ["(latest)"]
+2 -4
jmap-eio.opam
··· 6 6 maintainer: ["Anil Madhavapeddy <anil@recoil.org>"] 7 7 authors: ["Anil Madhavapeddy <anil@recoil.org>"] 8 8 license: "ISC" 9 - homepage: "https://github.com/avsm/ocaml-jmap" 10 - doc: "https://avsm.github.io/ocaml-jmap" 11 - bug-reports: "https://github.com/avsm/ocaml-jmap/issues" 9 + homepage: "https://tangled.org/@anil.recoil.org/ocaml-jmap" 10 + bug-reports: "https://tangled.org/@anil.recoil.org/ocaml-jmap/issues" 12 11 depends: [ 13 12 "dune" {>= "3.20"} 14 13 "ocaml" {>= "5.4.0"} ··· 32 31 "@doc" {with-doc} 33 32 ] 34 33 ] 35 - dev-repo: "git+https://github.com/avsm/ocaml-jmap.git" 36 34 x-maintenance-intent: ["(latest)"]
+2 -4
jmap.opam
··· 6 6 maintainer: ["Anil Madhavapeddy <anil@recoil.org>"] 7 7 authors: ["Anil Madhavapeddy <anil@recoil.org>"] 8 8 license: "ISC" 9 - homepage: "https://github.com/avsm/ocaml-jmap" 10 - doc: "https://avsm.github.io/ocaml-jmap" 11 - bug-reports: "https://github.com/avsm/ocaml-jmap/issues" 9 + homepage: "https://tangled.org/@anil.recoil.org/ocaml-jmap" 10 + bug-reports: "https://tangled.org/@anil.recoil.org/ocaml-jmap/issues" 12 11 depends: [ 13 12 "dune" {>= "3.20"} 14 13 "ocaml" {>= "5.4.0"} ··· 31 30 "@doc" {with-doc} 32 31 ] 33 32 ] 34 - dev-repo: "git+https://github.com/avsm/ocaml-jmap.git" 35 33 x-maintenance-intent: ["(latest)"]
+18 -8
lib/proto/proto_method.ml
··· 132 132 133 133 let set_response_jsont (type a) (obj_jsont : a Jsont.t) : a set_response Jsont.t = 134 134 let kind = "SetResponse" in 135 + (* All map/list fields in SetResponse can be null per RFC 8620 Section 5.3 *) 136 + (* opt_mem handles missing keys, Jsont.option handles explicit null values *) 137 + (* Option.join flattens option option -> option *) 138 + let join = Option.join in 135 139 let make account_id old_state new_state created updated destroyed 136 140 not_created not_updated not_destroyed = 137 - { account_id; old_state; new_state; created; updated; destroyed; 138 - not_created; not_updated; not_destroyed } 141 + { account_id; old_state; new_state; 142 + created = join created; 143 + updated = join updated; 144 + destroyed = join destroyed; 145 + not_created = join not_created; 146 + not_updated = join not_updated; 147 + not_destroyed = join not_destroyed } 139 148 in 140 149 (* For updated values, the server may return null or an object - RFC 8620 Section 5.3 *) 141 150 (* "Id[Foo|null]" means map values can be null, use Jsont.option to handle this *) 142 151 let nullable_obj = Jsont.(option obj_jsont) in 152 + let opt enc = Option.map Option.some enc in 143 153 Jsont.Object.map ~kind make 144 154 |> Jsont.Object.mem "accountId" Proto_id.jsont ~enc:(fun r -> r.account_id) 145 155 |> Jsont.Object.opt_mem "oldState" Jsont.string ~enc:(fun r -> r.old_state) 146 156 |> Jsont.Object.mem "newState" Jsont.string ~enc:(fun r -> r.new_state) 147 - |> Jsont.Object.opt_mem "created" (Proto_json_map.of_id obj_jsont) ~enc:(fun r -> r.created) 148 - |> Jsont.Object.opt_mem "updated" (Proto_json_map.of_id nullable_obj) ~enc:(fun r -> r.updated) 149 - |> Jsont.Object.opt_mem "destroyed" (Jsont.list Proto_id.jsont) ~enc:(fun r -> r.destroyed) 150 - |> Jsont.Object.opt_mem "notCreated" (Proto_json_map.of_id Proto_error.set_error_jsont) ~enc:(fun r -> r.not_created) 151 - |> Jsont.Object.opt_mem "notUpdated" (Proto_json_map.of_id Proto_error.set_error_jsont) ~enc:(fun r -> r.not_updated) 152 - |> Jsont.Object.opt_mem "notDestroyed" (Proto_json_map.of_id Proto_error.set_error_jsont) ~enc:(fun r -> r.not_destroyed) 157 + |> Jsont.Object.opt_mem "created" Jsont.(option (Proto_json_map.of_id obj_jsont)) ~enc:(fun r -> opt r.created) 158 + |> Jsont.Object.opt_mem "updated" Jsont.(option (Proto_json_map.of_id nullable_obj)) ~enc:(fun r -> opt r.updated) 159 + |> Jsont.Object.opt_mem "destroyed" Jsont.(option (list Proto_id.jsont)) ~enc:(fun r -> opt r.destroyed) 160 + |> Jsont.Object.opt_mem "notCreated" Jsont.(option (Proto_json_map.of_id Proto_error.set_error_jsont)) ~enc:(fun r -> opt r.not_created) 161 + |> Jsont.Object.opt_mem "notUpdated" Jsont.(option (Proto_json_map.of_id Proto_error.set_error_jsont)) ~enc:(fun r -> opt r.not_updated) 162 + |> Jsont.Object.opt_mem "notDestroyed" Jsont.(option (Proto_json_map.of_id Proto_error.set_error_jsont)) ~enc:(fun r -> opt r.not_destroyed) 153 163 |> Jsont.Object.finish 154 164 155 165 (* Foo/copy *)