this repo has no description
1(**
2 * JMAP protocol implementation based on RFC8620
3 * https://datatracker.ietf.org/doc/html/rfc8620
4 *)
5
6(** Module for managing JMAP capability URIs and other constants *)
7module Capability = struct
8 (** Core JMAP capability URI *)
9 type core = Core
10
11 (** JMAP capability URI as specified in RFC8620 *)
12 let core_uri = "urn:ietf:params:jmap:core"
13
14 (** Convert core capability to URI string *)
15 let string_of_core = function
16 | Core -> core_uri
17
18 (** Parse a string to a core capability, returns None if not a valid capability URI *)
19 let core_of_string = function
20 | s when s = core_uri -> Some Core
21 | _ -> None
22
23 (** All JMAP capability types *)
24 type t =
25 | Core of core
26 | Extension of string
27
28 (** Convert capability to URI string *)
29 let to_string = function
30 | Core c -> string_of_core c
31 | Extension s -> s
32
33 (** Parse a string to a capability, returns Extension for non-core capabilities *)
34 let of_string s =
35 match core_of_string s with
36 | Some c -> Core c
37 | None -> Extension s
38
39 (** Check if a capability matches a core capability *)
40 let is_core = function
41 | Core _ -> true
42 | Extension _ -> false
43
44 (** Check if a capability string is a core capability *)
45 let is_core_string s =
46 match of_string s with
47 | Core _ -> true
48 | Extension _ -> false
49
50 (** Create a list of capability strings *)
51 let strings_of_capabilities capabilities =
52 List.map to_string capabilities
53end
54
55module Types = struct
56 (** Id string as per Section 1.2 *)
57 type id = string
58
59 (** Int bounded within the range -2^53+1 to 2^53-1 as per Section 1.3 *)
60 type int_t = int
61
62 (** UnsignedInt bounded within the range 0 to 2^53-1 as per Section 1.3 *)
63 type unsigned_int = int
64
65 (** Date string in RFC3339 format as per Section 1.4 *)
66 type date = string
67
68 (** UTCDate is a Date with 'Z' time zone as per Section 1.4 *)
69 type utc_date = string
70
71 (** Error object as per Section 3.6.2 *)
72 type error = {
73 type_: string;
74 description: string option;
75 }
76
77 (** Set error object as per Section 5.3 *)
78 type set_error = {
79 type_: string;
80 description: string option;
81 properties: string list option;
82 (* Additional properties for specific error types *)
83 existing_id: id option; (* For alreadyExists error *)
84 }
85
86 (** Invocation object as per Section 3.2 *)
87 type 'a invocation = {
88 name: string;
89 arguments: 'a;
90 method_call_id: string;
91 }
92
93 (** ResultReference object as per Section 3.7 *)
94 type result_reference = {
95 result_of: string;
96 name: string;
97 path: string;
98 }
99
100 (** FilterOperator, FilterCondition and Filter as per Section 5.5 *)
101 type filter_operator = {
102 operator: string; (* "AND", "OR", "NOT" *)
103 conditions: filter list;
104 }
105 and filter_condition = (string * Ezjsonm.value) list
106 and filter =
107 | Operator of filter_operator
108 | Condition of filter_condition
109
110 (** Comparator object for sorting as per Section 5.5 *)
111 type comparator = {
112 property: string;
113 is_ascending: bool option; (* Optional, defaults to true *)
114 collation: string option; (* Optional, server-dependent default *)
115 }
116
117 (** PatchObject as per Section 5.3 *)
118 type patch_object = (string * Ezjsonm.value) list
119
120 (** AddedItem structure as per Section 5.6 *)
121 type added_item = {
122 id: id;
123 index: unsigned_int;
124 }
125
126 (** Account object as per Section 1.6.2 *)
127 type account = {
128 name: string;
129 is_personal: bool;
130 is_read_only: bool;
131 account_capabilities: (string * Ezjsonm.value) list;
132 }
133
134 (** Core capability object as per Section 2 *)
135 type core_capability = {
136 max_size_upload: unsigned_int;
137 max_concurrent_upload: unsigned_int;
138 max_size_request: unsigned_int;
139 max_concurrent_requests: unsigned_int;
140 max_calls_in_request: unsigned_int;
141 max_objects_in_get: unsigned_int;
142 max_objects_in_set: unsigned_int;
143 collation_algorithms: string list;
144 }
145
146 (** PushSubscription keys object as per Section 7.2 *)
147 type push_keys = {
148 p256dh: string;
149 auth: string;
150 }
151
152 (** Session object as per Section 2 *)
153 type session = {
154 capabilities: (string * Ezjsonm.value) list;
155 accounts: (id * account) list;
156 primary_accounts: (string * id) list;
157 username: string;
158 api_url: string;
159 download_url: string;
160 upload_url: string;
161 event_source_url: string option;
162 state: string;
163 }
164
165 (** TypeState for state changes as per Section 7.1 *)
166 type type_state = (string * string) list
167
168 (** StateChange object as per Section 7.1 *)
169 type state_change = {
170 changed: (id * type_state) list;
171 }
172
173 (** PushVerification object as per Section 7.2.2 *)
174 type push_verification = {
175 push_subscription_id: id;
176 verification_code: string;
177 }
178
179 (** PushSubscription object as per Section 7.2 *)
180 type push_subscription = {
181 id: id;
182 device_client_id: string;
183 url: string;
184 keys: push_keys option;
185 verification_code: string option;
186 expires: utc_date option;
187 types: string list option;
188 }
189
190 (** Request object as per Section 3.3 *)
191 type request = {
192 using: string list;
193 method_calls: Ezjsonm.value invocation list;
194 created_ids: (id * id) list option;
195 }
196
197 (** Response object as per Section 3.4 *)
198 type response = {
199 method_responses: Ezjsonm.value invocation list;
200 created_ids: (id * id) list option;
201 session_state: string;
202 }
203
204 (** Standard method arguments and responses *)
205
206 (** Arguments for Foo/get method as per Section 5.1 *)
207 type 'a get_arguments = {
208 account_id: id;
209 ids: id list option;
210 properties: string list option;
211 }
212
213 (** Response for Foo/get method as per Section 5.1 *)
214 type 'a get_response = {
215 account_id: id;
216 state: string;
217 list: 'a list;
218 not_found: id list;
219 }
220
221 (** Arguments for Foo/changes method as per Section 5.2 *)
222 type changes_arguments = {
223 account_id: id;
224 since_state: string;
225 max_changes: unsigned_int option;
226 }
227
228 (** Response for Foo/changes method as per Section 5.2 *)
229 type changes_response = {
230 account_id: id;
231 old_state: string;
232 new_state: string;
233 has_more_changes: bool;
234 created: id list;
235 updated: id list;
236 destroyed: id list;
237 }
238
239 (** Arguments for Foo/set method as per Section 5.3 *)
240 type 'a set_arguments = {
241 account_id: id;
242 if_in_state: string option;
243 create: (id * 'a) list option;
244 update: (id * patch_object) list option;
245 destroy: id list option;
246 }
247
248 (** Response for Foo/set method as per Section 5.3 *)
249 type 'a set_response = {
250 account_id: id;
251 old_state: string option;
252 new_state: string;
253 created: (id * 'a) list option;
254 updated: (id * 'a option) list option;
255 destroyed: id list option;
256 not_created: (id * set_error) list option;
257 not_updated: (id * set_error) list option;
258 not_destroyed: (id * set_error) list option;
259 }
260
261 (** Arguments for Foo/copy method as per Section 5.4 *)
262 type 'a copy_arguments = {
263 from_account_id: id;
264 if_from_in_state: string option;
265 account_id: id;
266 if_in_state: string option;
267 create: (id * 'a) list;
268 on_success_destroy_original: bool option;
269 destroy_from_if_in_state: string option;
270 }
271
272 (** Response for Foo/copy method as per Section 5.4 *)
273 type 'a copy_response = {
274 from_account_id: id;
275 account_id: id;
276 old_state: string option;
277 new_state: string;
278 created: (id * 'a) list option;
279 not_created: (id * set_error) list option;
280 }
281
282 (** Arguments for Foo/query method as per Section 5.5 *)
283 type query_arguments = {
284 account_id: id;
285 filter: filter option;
286 sort: comparator list option;
287 position: int_t option;
288 anchor: id option;
289 anchor_offset: int_t option;
290 limit: unsigned_int option;
291 calculate_total: bool option;
292 }
293
294 (** Response for Foo/query method as per Section 5.5 *)
295 type query_response = {
296 account_id: id;
297 query_state: string;
298 can_calculate_changes: bool;
299 position: unsigned_int;
300 ids: id list;
301 total: unsigned_int option;
302 limit: unsigned_int option;
303 }
304
305 (** Arguments for Foo/queryChanges method as per Section 5.6 *)
306 type query_changes_arguments = {
307 account_id: id;
308 filter: filter option;
309 sort: comparator list option;
310 since_query_state: string;
311 max_changes: unsigned_int option;
312 up_to_id: id option;
313 calculate_total: bool option;
314 }
315
316 (** Response for Foo/queryChanges method as per Section 5.6 *)
317 type query_changes_response = {
318 account_id: id;
319 old_query_state: string;
320 new_query_state: string;
321 total: unsigned_int option;
322 removed: id list;
323 added: added_item list option;
324 }
325
326 (** Arguments for Blob/copy method as per Section 6.3 *)
327 type blob_copy_arguments = {
328 from_account_id: id;
329 account_id: id;
330 blob_ids: id list;
331 }
332
333 (** Response for Blob/copy method as per Section 6.3 *)
334 type blob_copy_response = {
335 from_account_id: id;
336 account_id: id;
337 copied: (id * id) list option;
338 not_copied: (id * set_error) list option;
339 }
340
341 (** Upload response as per Section 6.1 *)
342 type upload_response = {
343 account_id: id;
344 blob_id: id;
345 type_: string;
346 size: unsigned_int;
347 }
348
349 (** Problem details object as per RFC7807 and Section 3.6.1 *)
350 type problem_details = {
351 type_: string;
352 status: int option;
353 detail: string option;
354 limit: string option; (* For "limit" error *)
355 }
356end
357
358module Api = struct
359 open Lwt.Syntax
360 open Types
361
362 (** Error that may occur during API requests *)
363 type error =
364 | Connection_error of string
365 | HTTP_error of int * string
366 | Parse_error of string
367 | Authentication_error
368
369 (** Result type for API operations *)
370 type 'a result = ('a, error) Stdlib.result
371
372 (** Configuration for a JMAP API client *)
373 type config = {
374 api_uri: Uri.t;
375 username: string;
376 authentication_token: string;
377 }
378
379 (** Convert Ezjsonm.value to string *)
380 let json_to_string json =
381 Ezjsonm.value_to_string ~minify:false json
382
383 (** Parse response string as JSON value *)
384 let parse_json_string str =
385 try Ok (Ezjsonm.from_string str)
386 with e -> Error (Parse_error (Printexc.to_string e))
387
388 (** Parse JSON response as a JMAP response object *)
389 let parse_response json =
390 try
391 let method_responses =
392 match Ezjsonm.find json ["methodResponses"] with
393 | `A items ->
394 List.map (fun json ->
395 match json with
396 | `A [`String name; args; `String method_call_id] ->
397 { name; arguments = args; method_call_id }
398 | _ -> raise (Invalid_argument "Invalid invocation format in response")
399 ) items
400 | _ -> raise (Invalid_argument "methodResponses is not an array")
401 in
402 let created_ids_opt =
403 try
404 let obj = Ezjsonm.find json ["createdIds"] in
405 match obj with
406 | `O items -> Some (List.map (fun (k, v) ->
407 match v with
408 | `String id -> (k, id)
409 | _ -> raise (Invalid_argument "createdIds value is not a string")
410 ) items)
411 | _ -> None
412 with Not_found -> None
413 in
414 let session_state =
415 match Ezjsonm.find json ["sessionState"] with
416 | `String s -> s
417 | _ -> raise (Invalid_argument "sessionState is not a string")
418 in
419 Ok { method_responses; created_ids = created_ids_opt; session_state }
420 with
421 | Not_found -> Error (Parse_error "Required field not found in response")
422 | Invalid_argument msg -> Error (Parse_error msg)
423 | e -> Error (Parse_error (Printexc.to_string e))
424
425 (** Serialize a JMAP request object to JSON *)
426 let serialize_request req =
427 let method_calls_json =
428 `A (List.map (fun (inv : 'a invocation) ->
429 `A [`String inv.name; inv.arguments; `String inv.method_call_id]
430 ) req.method_calls)
431 in
432 let using_json = `A (List.map (fun s -> `String s) req.using) in
433 let json = `O [
434 ("using", using_json);
435 ("methodCalls", method_calls_json)
436 ] in
437 let json = match req.created_ids with
438 | Some ids ->
439 let created_ids_json = `O (List.map (fun (k, v) -> (k, `String v)) ids) in
440 Ezjsonm.update json ["createdIds"] (Some created_ids_json)
441 | None -> json
442 in
443 json_to_string json
444
445 (** Make a raw HTTP request *)
446 let make_http_request ~method_ ~headers ~body uri =
447 let open Cohttp in
448 let open Cohttp_lwt_unix in
449 let headers = Header.add_list (Header.init ()) headers in
450
451 (* Debug: print request details *)
452 Printf.printf "\n===== HTTP REQUEST =====\n";
453 Printf.printf "URI: %s\n" (Uri.to_string uri);
454 Printf.printf "METHOD: %s\n" method_;
455 Printf.printf "HEADERS:\n";
456 Header.iter (fun k v -> Printf.printf " %s: %s\n" k v) headers;
457 Printf.printf "BODY:\n%s\n" body;
458 Printf.printf "======================\n\n";
459
460 Lwt.catch
461 (fun () ->
462 let* resp, body =
463 match method_ with
464 | "GET" -> Client.get ~headers uri
465 | "POST" -> Client.post ~headers ~body:(Cohttp_lwt.Body.of_string body) uri
466 | _ -> failwith (Printf.sprintf "Unsupported HTTP method: %s" method_)
467 in
468 let* body_str = Cohttp_lwt.Body.to_string body in
469 let status = Response.status resp |> Code.code_of_status in
470
471 (* Debug: print response details *)
472 Printf.printf "\n===== HTTP RESPONSE =====\n";
473 Printf.printf "STATUS: %d\n" status;
474 Printf.printf "HEADERS:\n";
475 Response.headers resp |> Header.iter (fun k v -> Printf.printf " %s: %s\n" k v);
476 Printf.printf "BODY:\n%s\n" body_str;
477 Printf.printf "========================\n\n";
478
479 if status >= 200 && status < 300 then
480 Lwt.return (Ok body_str)
481 else
482 Lwt.return (Error (HTTP_error (status, body_str))))
483 (fun e ->
484 let error_msg = Printexc.to_string e in
485 Printf.printf "\n===== HTTP ERROR =====\n%s\n======================\n\n" error_msg;
486 Lwt.return (Error (Connection_error error_msg)))
487
488 (** Make a raw JMAP API request
489
490 TODO:claude *)
491 let make_request config req =
492 let body = serialize_request req in
493 (* Choose appropriate authorization header based on whether it's a bearer token or basic auth *)
494 let auth_header =
495 if String.length config.username > 0 then
496 (* Standard username/password authentication *)
497 "Basic " ^ Base64.encode_string (config.username ^ ":" ^ config.authentication_token)
498 else
499 (* API token (bearer authentication) *)
500 "Bearer " ^ config.authentication_token
501 in
502 Printf.printf "Using authorization header: %s\n" auth_header;
503 let headers = [
504 ("Content-Type", "application/json");
505 ("Content-Length", string_of_int (String.length body));
506 ("Authorization", auth_header)
507 ] in
508 let* result = make_http_request ~method_:"POST" ~headers ~body config.api_uri in
509 match result with
510 | Ok response_body ->
511 (match parse_json_string response_body with
512 | Ok json -> Lwt.return (parse_response json)
513 | Error e -> Lwt.return (Error e))
514 | Error e -> Lwt.return (Error e)
515
516 (** Parse a JSON object as a Session object *)
517 let parse_session_object json =
518 try
519 let capabilities =
520 match Ezjsonm.find json ["capabilities"] with
521 | `O items -> items
522 | _ -> raise (Invalid_argument "capabilities is not an object")
523 in
524
525 let accounts =
526 match Ezjsonm.find json ["accounts"] with
527 | `O items -> List.map (fun (id, json) ->
528 match json with
529 | `O _ ->
530 let name = Ezjsonm.get_string (Ezjsonm.find json ["name"]) in
531 let is_personal = Ezjsonm.get_bool (Ezjsonm.find json ["isPersonal"]) in
532 let is_read_only = Ezjsonm.get_bool (Ezjsonm.find json ["isReadOnly"]) in
533 let account_capabilities =
534 match Ezjsonm.find json ["accountCapabilities"] with
535 | `O items -> items
536 | _ -> raise (Invalid_argument "accountCapabilities is not an object")
537 in
538 (id, { name; is_personal; is_read_only; account_capabilities })
539 | _ -> raise (Invalid_argument "account value is not an object")
540 ) items
541 | _ -> raise (Invalid_argument "accounts is not an object")
542 in
543
544 let primary_accounts =
545 match Ezjsonm.find_opt json ["primaryAccounts"] with
546 | Some (`O items) -> List.map (fun (k, v) ->
547 match v with
548 | `String id -> (k, id)
549 | _ -> raise (Invalid_argument "primaryAccounts value is not a string")
550 ) items
551 | Some _ -> raise (Invalid_argument "primaryAccounts is not an object")
552 | None -> []
553 in
554
555 let username = Ezjsonm.get_string (Ezjsonm.find json ["username"]) in
556 let api_url = Ezjsonm.get_string (Ezjsonm.find json ["apiUrl"]) in
557 let download_url = Ezjsonm.get_string (Ezjsonm.find json ["downloadUrl"]) in
558 let upload_url = Ezjsonm.get_string (Ezjsonm.find json ["uploadUrl"]) in
559 let event_source_url =
560 try Some (Ezjsonm.get_string (Ezjsonm.find json ["eventSourceUrl"]))
561 with Not_found -> None
562 in
563 let state = Ezjsonm.get_string (Ezjsonm.find json ["state"]) in
564
565 Ok { capabilities; accounts; primary_accounts; username;
566 api_url; download_url; upload_url; event_source_url; state }
567 with
568 | Not_found -> Error (Parse_error "Required field not found in session object")
569 | Invalid_argument msg -> Error (Parse_error msg)
570 | e -> Error (Parse_error (Printexc.to_string e))
571
572 (** Fetch a Session object from a JMAP server
573
574 TODO:claude *)
575 let get_session uri ?username ?authentication_token ?api_token () =
576 let headers =
577 match (username, authentication_token, api_token) with
578 | (Some u, Some t, _) ->
579 let auth = "Basic " ^ Base64.encode_string (u ^ ":" ^ t) in
580 Printf.printf "Session using Basic auth: %s\n" auth;
581 [
582 ("Content-Type", "application/json");
583 ("Authorization", auth)
584 ]
585 | (_, _, Some token) ->
586 let auth = "Bearer " ^ token in
587 Printf.printf "Session using Bearer auth: %s\n" auth;
588 [
589 ("Content-Type", "application/json");
590 ("Authorization", auth)
591 ]
592 | _ -> [("Content-Type", "application/json")]
593 in
594
595 let* result = make_http_request ~method_:"GET" ~headers ~body:"" uri in
596 match result with
597 | Ok response_body ->
598 (match parse_json_string response_body with
599 | Ok json -> Lwt.return (parse_session_object json)
600 | Error e -> Lwt.return (Error e))
601 | Error e -> Lwt.return (Error e)
602
603 (** Upload a binary blob to the server
604
605 TODO:claude *)
606 let upload_blob config ~account_id ~content_type data =
607 let upload_url_template = config.api_uri |> Uri.to_string in
608 (* Replace {accountId} with the actual account ID *)
609 let upload_url = Str.global_replace (Str.regexp "{accountId}") account_id upload_url_template in
610 let upload_uri = Uri.of_string upload_url in
611
612 let headers = [
613 ("Content-Type", content_type);
614 ("Content-Length", string_of_int (String.length data));
615 ("Authorization", "Basic " ^ Base64.encode_string (config.username ^ ":" ^ config.authentication_token))
616 ] in
617
618 let* result = make_http_request ~method_:"POST" ~headers ~body:data upload_uri in
619 match result with
620 | Ok response_body ->
621 (match parse_json_string response_body with
622 | Ok json ->
623 (try
624 let account_id = Ezjsonm.get_string (Ezjsonm.find json ["accountId"]) in
625 let blob_id = Ezjsonm.get_string (Ezjsonm.find json ["blobId"]) in
626 let type_ = Ezjsonm.get_string (Ezjsonm.find json ["type"]) in
627 let size = Ezjsonm.get_int (Ezjsonm.find json ["size"]) in
628 Lwt.return (Ok { account_id; blob_id; type_; size })
629 with
630 | Not_found -> Lwt.return (Error (Parse_error "Required field not found in upload response"))
631 | e -> Lwt.return (Error (Parse_error (Printexc.to_string e))))
632 | Error e -> Lwt.return (Error e))
633 | Error e -> Lwt.return (Error e)
634
635 (** Download a binary blob from the server
636
637 TODO:claude *)
638 let download_blob config ~account_id ~blob_id ?type_ ?name () =
639 let download_url_template = config.api_uri |> Uri.to_string in
640
641 (* Replace template variables with actual values *)
642 let url = Str.global_replace (Str.regexp "{accountId}") account_id download_url_template in
643 let url = Str.global_replace (Str.regexp "{blobId}") blob_id url in
644
645 let url = match type_ with
646 | Some t -> Str.global_replace (Str.regexp "{type}") (Uri.pct_encode t) url
647 | None -> Str.global_replace (Str.regexp "{type}") "" url
648 in
649
650 let url = match name with
651 | Some n -> Str.global_replace (Str.regexp "{name}") (Uri.pct_encode n) url
652 | None -> Str.global_replace (Str.regexp "{name}") "file" url
653 in
654
655 let download_uri = Uri.of_string url in
656
657 let headers = [
658 ("Authorization", "Basic " ^ Base64.encode_string (config.username ^ ":" ^ config.authentication_token))
659 ] in
660
661 let* result = make_http_request ~method_:"GET" ~headers ~body:"" download_uri in
662 Lwt.return result
663end