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