···2525let req = Jmap_core.Jmap_request.Parser.of_json request_json in
2626```
27272828-### ✅ Good - Using the JMAP library API:
2828+### ✅ Good - Using the typed JMAP library API:
2929```ocaml
3030-(* Build query arguments *)
3131-let query_args = `O [
3232- ("accountId", `String account_id);
3333- ("limit", `Float 10.);
3434- ("sort", `A [`O [("property", `String "receivedAt"); ("isAscending", `Bool false)]]);
3535- ("calculateTotal", `Bool true);
3636-] in
3030+(* Build Email/query request using typed constructors *)
3131+let query_request = Jmap_mail.Jmap_email.Query.request_v
3232+ ~account_id:(Jmap_core.Jmap_id.of_string account_id)
3333+ ~limit:(Jmap_core.Jmap_primitives.UnsignedInt.of_int 10)
3434+ ~sort:[Jmap_core.Jmap_comparator.v ~property:"receivedAt" ~is_ascending:false ()]
3535+ ~calculate_total:true
3636+ () in
37373838-(* Create invocation using Echo witness for generic JSON *)
3939-let invocation = Jmap_invocation.Invocation {
3838+(* Convert to JSON *)
3939+let query_args = Jmap_mail.Jmap_email.Query.request_to_json query_request in
4040+4141+(* Create invocation using Echo witness *)
4242+let query_invocation = Jmap_core.Jmap_invocation.Invocation {
4043 method_name = "Email/query";
4144 arguments = query_args;
4242- call_id = "c1";
4343- witness = Jmap_invocation.Echo;
4545+ call_id = "q1";
4646+ witness = Jmap_core.Jmap_invocation.Echo;
4447} in
45484649(* Build request using constructors *)
4747-let req = Jmap_request.make
4848- ~using:[Jmap_capability.core; Jmap_capability.mail]
4949- [Jmap_invocation.Packed invocation]
5050+let req = Jmap_core.Jmap_request.make
5151+ ~using:[Jmap_core.Jmap_capability.core; Jmap_core.Jmap_capability.mail]
5252+ [Jmap_core.Jmap_invocation.Packed query_invocation]
5053in
5454+5555+(* Make the call *)
5656+let query_resp = Jmap_client.call client req in
5757+5858+(* Extract results using type-safe response_to_json *)
5959+let method_responses = Jmap_core.Jmap_response.method_responses query_resp in
6060+match method_responses with
6161+| [packed_resp] ->
6262+ let response_json = Jmap_core.Jmap_invocation.response_to_json packed_resp in
6363+ (* Now parse response_json... *)
6464+ (match response_json with
6565+ | `O fields ->
6666+ (match List.assoc_opt "ids" fields with
6767+ | Some (`A ids) -> (* process ids... *)
6868+ | _ -> ())
6969+ | _ -> ())
7070+| _ -> failwith "Unexpected response"
5171```
7272+7373+The key principles:
7474+1. Use typed `request_v` constructors (e.g., `Email.Query.request_v`, `Email.Get.request_v`)
7575+2. Convert typed requests to JSON with `request_to_json`
7676+3. Wrap in invocations and build JMAP requests with `Jmap_request.make`
7777+4. Use `Jmap_invocation.response_to_json` to safely extract response data from packed responses
52785379## Architecture
5480···105131## Current Limitations
106132107133- Full typed method support is partially implemented
108108-- Some methods still use Echo witness with raw JSON arguments
109109-- Response parsing extracts raw JSON rather than fully typed responses
134134+- Methods use Echo witness with JSON arguments/responses (type-safe from user perspective)
135135+- Response parsing stores raw JSON with Echo witness, then `response_to_json` provides type-safe access
136136+137137+### Type Safety - Zero Obj.magic
138138+139139+**The entire JMAP library is completely free of `Obj.magic`**. The library provides:
140140+- `response_to_json` to safely extract responses from packed types
141141+- Typed constructors for building requests
142142+- Type-safe JSON conversion functions
143143+144144+The implementation uses Echo witness for all invocations/responses, storing `Ezjsonm.value` directly:
145145+```ocaml
146146+| Echo -> response (* response is already Ezjsonm.value - completely type-safe! *)
147147+```
148148+149149+Non-Echo witness cases (Get, Query, etc.) immediately fail with descriptive error messages if called, ensuring that any misuse is caught immediately rather than silently corrupting data with unsafe casts.
150150+151151+When full typed witnesses are implemented in the future, proper serialization functions will be added to support them safely.
110152111153These will be improved as the library matures.
+41-8
stack/jmap/jmap-core/jmap_invocation.ml
···162162 let args_json : Ezjsonm.value = match witness with
163163 | Echo -> arguments (* Echo arguments are already Ezjsonm.value *)
164164 | Get _ ->
165165- (* For Get, need to serialize Get.request *)
166166- (* For now, assume arguments is already JSON (hack from parsing) *)
167167- (Obj.magic arguments : Ezjsonm.value)
168168- | Changes _ -> (Obj.magic arguments : Ezjsonm.value)
169169- | Set _ -> (Obj.magic arguments : Ezjsonm.value)
170170- | Copy _ -> (Obj.magic arguments : Ezjsonm.value)
171171- | Query _ -> (Obj.magic arguments : Ezjsonm.value)
172172- | QueryChanges _ -> (Obj.magic arguments : Ezjsonm.value)
165165+ (* This code path should never execute - we only create invocations with Echo witness.
166166+ If it does execute, fail immediately rather than using unsafe magic. *)
167167+ failwith "to_json: Get witness not supported - use Echo witness with pre-serialized JSON"
168168+ | Changes _ ->
169169+ failwith "to_json: Changes witness not supported - use Echo witness with pre-serialized JSON"
170170+ | Set _ ->
171171+ failwith "to_json: Set witness not supported - use Echo witness with pre-serialized JSON"
172172+ | Copy _ ->
173173+ failwith "to_json: Copy witness not supported - use Echo witness with pre-serialized JSON"
174174+ | Query _ ->
175175+ failwith "to_json: Query witness not supported - use Echo witness with pre-serialized JSON"
176176+ | QueryChanges _ ->
177177+ failwith "to_json: QueryChanges witness not supported - use Echo witness with pre-serialized JSON"
173178 in
174179 `A [`String method_name; args_json; `String call_id]
180180+181181+(** Extract response data as JSON from a packed response.
182182+ This provides safe access to response data.
183183+184184+ NOTE: Currently all responses are parsed with Echo witness and stored as
185185+ Ezjsonm.value, so only the Echo case executes. The other cases will fail
186186+ immediately if called - they should never execute in the current implementation. *)
187187+let response_to_json : packed_response -> Ezjsonm.value = function
188188+ | PackedResponse (ResponseInvocation { response; witness; _ }) ->
189189+ (* Pattern match on witness to convert response to JSON type-safely *)
190190+ match witness with
191191+ | Echo ->
192192+ (* For Echo witness, response is already Ezjsonm.value - completely type-safe! *)
193193+ response
194194+ | Get _ ->
195195+ (* This code path should never execute - we only create responses with Echo witness.
196196+ If it does execute, fail immediately rather than using unsafe magic. *)
197197+ failwith "response_to_json: Get witness not supported - responses use Echo witness"
198198+ | Changes _ ->
199199+ failwith "response_to_json: Changes witness not supported - responses use Echo witness"
200200+ | Set _ ->
201201+ failwith "response_to_json: Set witness not supported - responses use Echo witness"
202202+ | Copy _ ->
203203+ failwith "response_to_json: Copy witness not supported - responses use Echo witness"
204204+ | Query _ ->
205205+ failwith "response_to_json: Query witness not supported - responses use Echo witness"
206206+ | QueryChanges _ ->
207207+ failwith "response_to_json: QueryChanges witness not supported - responses use Echo witness"
+3
stack/jmap/jmap-core/jmap_invocation.mli
···65656666(** Convert invocation to JSON *)
6767val to_json : 'resp invocation -> Ezjsonm.value
6868+6969+(** Extract response data as JSON from a packed response *)
7070+val response_to_json : packed_response -> Ezjsonm.value
+163
stack/jmap/jmap-mail/jmap_email.ml
···686686 all_in_thread_have_keyword; some_in_thread_have_keyword;
687687 none_in_thread_have_keyword; has_keyword; not_keyword; has_attachment;
688688 text; from; to_; cc; bcc; subject; body; header }
689689+690690+ (* Convert to JSON *)
691691+ let to_json t =
692692+ let fields = [] in
693693+ let fields = match t.in_mailbox with
694694+ | Some id -> ("inMailbox", Jmap_core.Jmap_id.to_json id) :: fields
695695+ | None -> fields
696696+ in
697697+ let fields = match t.in_mailbox_other_than with
698698+ | Some ids -> ("inMailboxOtherThan", `A (List.map Jmap_core.Jmap_id.to_json ids)) :: fields
699699+ | None -> fields
700700+ in
701701+ let fields = match t.before with
702702+ | Some d -> ("before", `String (Jmap_core.Jmap_primitives.UTCDate.to_string d)) :: fields
703703+ | None -> fields
704704+ in
705705+ let fields = match t.after with
706706+ | Some d -> ("after", `String (Jmap_core.Jmap_primitives.UTCDate.to_string d)) :: fields
707707+ | None -> fields
708708+ in
709709+ let fields = match t.min_size with
710710+ | Some s -> ("minSize", Jmap_core.Jmap_primitives.UnsignedInt.to_json s) :: fields
711711+ | None -> fields
712712+ in
713713+ let fields = match t.max_size with
714714+ | Some s -> ("maxSize", Jmap_core.Jmap_primitives.UnsignedInt.to_json s) :: fields
715715+ | None -> fields
716716+ in
717717+ let fields = match t.all_in_thread_have_keyword with
718718+ | Some k -> ("allInThreadHaveKeyword", `String k) :: fields
719719+ | None -> fields
720720+ in
721721+ let fields = match t.some_in_thread_have_keyword with
722722+ | Some k -> ("someInThreadHaveKeyword", `String k) :: fields
723723+ | None -> fields
724724+ in
725725+ let fields = match t.none_in_thread_have_keyword with
726726+ | Some k -> ("noneInThreadHaveKeyword", `String k) :: fields
727727+ | None -> fields
728728+ in
729729+ let fields = match t.has_keyword with
730730+ | Some k -> ("hasKeyword", `String k) :: fields
731731+ | None -> fields
732732+ in
733733+ let fields = match t.not_keyword with
734734+ | Some k -> ("notKeyword", `String k) :: fields
735735+ | None -> fields
736736+ in
737737+ let fields = match t.has_attachment with
738738+ | Some b -> ("hasAttachment", `Bool b) :: fields
739739+ | None -> fields
740740+ in
741741+ let fields = match t.text with
742742+ | Some s -> ("text", `String s) :: fields
743743+ | None -> fields
744744+ in
745745+ let fields = match t.from with
746746+ | Some s -> ("from", `String s) :: fields
747747+ | None -> fields
748748+ in
749749+ let fields = match t.to_ with
750750+ | Some s -> ("to", `String s) :: fields
751751+ | None -> fields
752752+ in
753753+ let fields = match t.cc with
754754+ | Some s -> ("cc", `String s) :: fields
755755+ | None -> fields
756756+ in
757757+ let fields = match t.bcc with
758758+ | Some s -> ("bcc", `String s) :: fields
759759+ | None -> fields
760760+ in
761761+ let fields = match t.subject with
762762+ | Some s -> ("subject", `String s) :: fields
763763+ | None -> fields
764764+ in
765765+ let fields = match t.body with
766766+ | Some s -> ("body", `String s) :: fields
767767+ | None -> fields
768768+ in
769769+ let fields = match t.header with
770770+ | Some hdrs ->
771771+ let hdr_arr = List.map (fun (name, value) ->
772772+ `O [("name", `String name); ("value", `String value)]
773773+ ) hdrs in
774774+ ("header", `A hdr_arr) :: fields
775775+ | None -> fields
776776+ in
777777+ `O fields
689778end
690779691780(** Standard /get method (RFC 8621 Section 4.2) *)
···774863 *)
775864 let response_of_json json =
776865 Jmap_core.Jmap_standard_methods.Get.response_of_json of_json json
866866+867867+ (** Convert get request to JSON *)
868868+ let request_to_json req =
869869+ let fields = [
870870+ ("accountId", Jmap_core.Jmap_id.to_json req.account_id);
871871+ ] in
872872+ let fields = match req.ids with
873873+ | Some ids -> ("ids", `A (List.map Jmap_core.Jmap_id.to_json ids)) :: fields
874874+ | None -> fields
875875+ in
876876+ let fields = match req.properties with
877877+ | Some props -> ("properties", `A (List.map (fun s -> `String s) props)) :: fields
878878+ | None -> fields
879879+ in
880880+ let fields = match req.body_properties with
881881+ | Some bp -> ("bodyProperties", `A (List.map (fun s -> `String s) bp)) :: fields
882882+ | None -> fields
883883+ in
884884+ let fields = match req.fetch_text_body_values with
885885+ | Some ftbv -> ("fetchTextBodyValues", `Bool ftbv) :: fields
886886+ | None -> fields
887887+ in
888888+ let fields = match req.fetch_html_body_values with
889889+ | Some fhbv -> ("fetchHTMLBodyValues", `Bool fhbv) :: fields
890890+ | None -> fields
891891+ in
892892+ let fields = match req.fetch_all_body_values with
893893+ | Some fabv -> ("fetchAllBodyValues", `Bool fabv) :: fields
894894+ | None -> fields
895895+ in
896896+ let fields = match req.max_body_value_bytes with
897897+ | Some mbvb -> ("maxBodyValueBytes", Jmap_core.Jmap_primitives.UnsignedInt.to_json mbvb) :: fields
898898+ | None -> fields
899899+ in
900900+ `O fields
777901end
778902779903(** Standard /changes method (RFC 8621 Section 4.3) *)
···871995 Test files: test/data/mail/email_query_response.json *)
872996 let response_of_json json =
873997 Jmap_core.Jmap_standard_methods.Query.response_of_json json
998998+999999+ (** Convert query request to JSON *)
10001000+ let request_to_json req =
10011001+ let fields = [
10021002+ ("accountId", Jmap_core.Jmap_id.to_json req.account_id);
10031003+ ] in
10041004+ let fields = match req.filter with
10051005+ | Some f -> ("filter", Jmap_core.Jmap_filter.to_json Filter.to_json f) :: fields
10061006+ | None -> fields
10071007+ in
10081008+ let fields = match req.sort with
10091009+ | Some s -> ("sort", `A (List.map Jmap_core.Jmap_comparator.to_json s)) :: fields
10101010+ | None -> fields
10111011+ in
10121012+ let fields = match req.position with
10131013+ | Some p -> ("position", Jmap_core.Jmap_primitives.Int53.to_json p) :: fields
10141014+ | None -> fields
10151015+ in
10161016+ let fields = match req.anchor with
10171017+ | Some a -> ("anchor", Jmap_core.Jmap_id.to_json a) :: fields
10181018+ | None -> fields
10191019+ in
10201020+ let fields = match req.anchor_offset with
10211021+ | Some ao -> ("anchorOffset", Jmap_core.Jmap_primitives.Int53.to_json ao) :: fields
10221022+ | None -> fields
10231023+ in
10241024+ let fields = match req.limit with
10251025+ | Some l -> ("limit", Jmap_core.Jmap_primitives.UnsignedInt.to_json l) :: fields
10261026+ | None -> fields
10271027+ in
10281028+ let fields = match req.calculate_total with
10291029+ | Some ct -> ("calculateTotal", `Bool ct) :: fields
10301030+ | None -> fields
10311031+ in
10321032+ let fields = match req.collapse_threads with
10331033+ | Some ct -> ("collapseThreads", `Bool ct) :: fields
10341034+ | None -> fields
10351035+ in
10361036+ `O fields
8741037end
87510388761039(** Standard /queryChanges method (RFC 8621 Section 4.5) *)
+3
stack/jmap/jmap-mail/jmap_email.mli
···269269 t
270270271271 val of_json : Ezjsonm.value -> t
272272+ val to_json : t -> Ezjsonm.value
272273end
273274274275(** Standard /get method *)
···310311 request
311312312313 val request_of_json : Ezjsonm.value -> request
314314+ val request_to_json : request -> Ezjsonm.value
313315 val response_of_json : Ezjsonm.value -> response
314316end
315317···364366 request
365367366368 val request_of_json : Ezjsonm.value -> request
369369+ val request_to_json : request -> Ezjsonm.value
367370 val response_of_json : Ezjsonm.value -> response
368371end
369372
+109-17
stack/jmap/test/test_fastmail.ml
···6868 in
6969 Printf.printf " Account ID: %s\n\n%!" account_id;
70707171- (* Build a JMAP request using the library API *)
7171+ (* Build a JMAP request using the typed library API *)
7272 Printf.printf "Querying for 10 most recent emails...\n";
7373 Printf.printf " API URL: %s\n%!" (Jmap_core.Jmap_session.api_url session);
74747575- (* Build query arguments *)
7676- let query_args = `O [
7777- ("accountId", `String account_id);
7878- ("limit", `Float 10.);
7979- ("sort", `A [`O [("property", `String "receivedAt"); ("isAscending", `Bool false)]]);
8080- ("calculateTotal", `Bool true);
8181- ] in
7575+ (* Build Email/query request using typed constructors *)
7676+ let query_request = Jmap_mail.Jmap_email.Query.request_v
7777+ ~account_id:(Jmap_core.Jmap_id.of_string account_id)
7878+ ~limit:(Jmap_core.Jmap_primitives.UnsignedInt.of_int 10)
7979+ ~sort:[Jmap_core.Jmap_comparator.v ~property:"receivedAt" ~is_ascending:false ()]
8080+ ~calculate_total:true
8181+ () in
82828383- (* Create invocation using Echo witness for generic JSON *)
8484- let invocation = Jmap_core.Jmap_invocation.Invocation {
8383+ (* Convert to JSON *)
8484+ let query_args = Jmap_mail.Jmap_email.Query.request_to_json query_request in
8585+8686+ (* Create invocation using Echo witness *)
8787+ let query_invocation = Jmap_core.Jmap_invocation.Invocation {
8588 method_name = "Email/query";
8689 arguments = query_args;
8787- call_id = "c1";
9090+ call_id = "q1";
8891 witness = Jmap_core.Jmap_invocation.Echo;
8992 } in
90939194 (* Build request using constructors *)
9295 let req = Jmap_core.Jmap_request.make
9396 ~using:[Jmap_core.Jmap_capability.core; Jmap_core.Jmap_capability.mail]
9494- [Jmap_core.Jmap_invocation.Packed invocation]
9797+ [Jmap_core.Jmap_invocation.Packed query_invocation]
9598 in
96999797- Printf.printf " Request built using JMAP library API\n%!";
100100+ Printf.printf " Request built using typed Email.Query API\n%!";
9810199102 Printf.printf " Making API call...\n%!";
100103 (try
101101- let resp = Jmap_client.call client req in
104104+ let query_resp = Jmap_client.call client req in
102105 Printf.printf "✓ Query successful!\n";
103103- Printf.printf " Session state: %s\n" (Jmap_core.Jmap_response.session_state resp);
104104- Printf.printf "\n✓ Test completed successfully!\n%!"
106106+107107+ (* Extract email IDs from the query response *)
108108+ let method_responses = Jmap_core.Jmap_response.method_responses query_resp in
109109+ let email_ids = match method_responses with
110110+ | [packed_resp] ->
111111+ let response_json = Jmap_core.Jmap_invocation.response_to_json packed_resp in
112112+ (match response_json with
113113+ | `O fields ->
114114+ (match List.assoc_opt "ids" fields with
115115+ | Some (`A ids) ->
116116+ List.map (fun id ->
117117+ match id with
118118+ | `String s -> Jmap_core.Jmap_id.of_string s
119119+ | _ -> failwith "Expected string ID"
120120+ ) ids
121121+ | _ -> failwith "No 'ids' field in query response")
122122+ | _ -> failwith "Expected object response")
123123+ | _ -> failwith "Unexpected response structure"
124124+ in
125125+126126+ Printf.printf " Found %d email(s)\n\n%!" (List.length email_ids);
127127+128128+ if List.length email_ids > 0 then (
129129+ (* Fetch the actual emails with Email/get *)
130130+ let get_request = Jmap_mail.Jmap_email.Get.request_v
131131+ ~account_id:(Jmap_core.Jmap_id.of_string account_id)
132132+ ~ids:email_ids
133133+ ~properties:["id"; "subject"; "from"; "receivedAt"]
134134+ () in
135135+136136+ let get_args = Jmap_mail.Jmap_email.Get.request_to_json get_request in
137137+138138+ let get_invocation = Jmap_core.Jmap_invocation.Invocation {
139139+ method_name = "Email/get";
140140+ arguments = get_args;
141141+ call_id = "g1";
142142+ witness = Jmap_core.Jmap_invocation.Echo;
143143+ } in
144144+145145+ let get_req = Jmap_core.Jmap_request.make
146146+ ~using:[Jmap_core.Jmap_capability.core; Jmap_core.Jmap_capability.mail]
147147+ [Jmap_core.Jmap_invocation.Packed get_invocation]
148148+ in
149149+150150+ let get_resp = Jmap_client.call client get_req in
151151+152152+ (* Parse and display emails *)
153153+ let get_method_responses = Jmap_core.Jmap_response.method_responses get_resp in
154154+ (match get_method_responses with
155155+ | [packed_resp] ->
156156+ let response_json = Jmap_core.Jmap_invocation.response_to_json packed_resp in
157157+ (match response_json with
158158+ | `O fields ->
159159+ (match List.assoc_opt "list" fields with
160160+ | Some (`A emails) ->
161161+ Printf.printf "Recent emails:\n\n";
162162+ List.iteri (fun i email_json ->
163163+ match email_json with
164164+ | `O email_fields ->
165165+ let subject = match List.assoc_opt "subject" email_fields with
166166+ | Some (`String s) -> s
167167+ | _ -> "(no subject)"
168168+ in
169169+ let from = match List.assoc_opt "from" email_fields with
170170+ | Some (`A []) -> "(unknown sender)"
171171+ | Some (`A ((`O addr_fields)::_)) ->
172172+ (match List.assoc_opt "email" addr_fields with
173173+ | Some (`String e) ->
174174+ (match List.assoc_opt "name" addr_fields with
175175+ | Some (`String n) -> Printf.sprintf "%s <%s>" n e
176176+ | _ -> e)
177177+ | _ -> "(unknown)")
178178+ | _ -> "(unknown sender)"
179179+ in
180180+ let date = match List.assoc_opt "receivedAt" email_fields with
181181+ | Some (`String d) -> d
182182+ | _ -> "(unknown date)"
183183+ in
184184+ Printf.printf "%d. %s\n" (i + 1) subject;
185185+ Printf.printf " From: %s\n" from;
186186+ Printf.printf " Date: %s\n\n" date
187187+ | _ -> ()
188188+ ) emails
189189+ | _ -> Printf.printf "No emails in response\n")
190190+ | _ -> Printf.printf "Unexpected response format\n")
191191+ | _ -> Printf.printf "Unexpected method response structure\n");
192192+193193+ Printf.printf "\n✓ Test completed successfully!\n%!"
194194+ ) else (
195195+ Printf.printf "No emails found\n";
196196+ Printf.printf "\n✓ Test completed successfully!\n%!"
197197+ )
105198 with
106199 | Failure msg when String.starts_with ~prefix:"JMAP API call failed: HTTP" msg ->
107200 Printf.eprintf "API call failed with error: %s\n" msg;
108201 Printf.eprintf "This likely means the request JSON is malformed.\n";
109109- Printf.eprintf "Check the request JSON above.\n";
110202 exit 1
111203 | e ->
112204 Printf.eprintf "Error making API call: %s\n%!" (Printexc.to_string e);