(** * JMAP protocol implementation based on RFC8620 * https://datatracker.ietf.org/doc/html/rfc8620 *) (** Whether to redact sensitive information *) let should_redact_sensitive = ref true (** Initialize and configure logging for JMAP *) let init_logging ?(level=2) ?(enable_logs=true) ?(redact_sensitive=true) () = if enable_logs then begin Logs.set_reporter (Logs.format_reporter ()); match level with | 0 -> Logs.set_level None | 1 -> Logs.set_level (Some Logs.Error) | 2 -> Logs.set_level (Some Logs.Info) | 3 -> Logs.set_level (Some Logs.Debug) | _ -> Logs.set_level (Some Logs.Debug) end else Logs.set_level None; should_redact_sensitive := redact_sensitive (** Redact sensitive data like tokens *) let redact_token ?(redact=true) token = if redact && !should_redact_sensitive && String.length token > 8 then let prefix = String.sub token 0 4 in let suffix = String.sub token (String.length token - 4) 4 in prefix ^ "..." ^ suffix else token (** Redact sensitive headers like Authorization *) let redact_headers headers = List.map (fun (k, v) -> if String.lowercase_ascii k = "authorization" then if !should_redact_sensitive then let parts = String.split_on_char ' ' v in match parts with | scheme :: token :: _ -> (k, scheme ^ " " ^ redact_token token) | _ -> (k, v) else (k, v) else (k, v) ) headers (* Initialize logging with defaults *) let () = init_logging () (** Module for managing JMAP capability URIs and other constants *) module Capability = struct (** JMAP capability URI as specified in RFC8620 *) let core_uri = "urn:ietf:params:jmap:core" (** All JMAP capability types *) type t = | Core (** Core JMAP capability *) | Extension of string (** Extension capabilities *) (** Convert capability to URI string *) let to_string = function | Core -> core_uri | Extension s -> s (** Parse a string to a capability, returns Extension for non-core capabilities *) let of_string s = if s = core_uri then Core else Extension s (** Check if a capability matches a core capability *) let is_core = function | Core -> true | Extension _ -> false (** Check if a capability string is a core capability *) let is_core_string s = s = core_uri (** Create a list of capability strings *) let strings_of_capabilities capabilities = List.map to_string capabilities end module Types = struct (** Id string as per Section 1.2 *) type id = string (** Int bounded within the range -2^53+1 to 2^53-1 as per Section 1.3 *) type int_t = int (** UnsignedInt bounded within the range 0 to 2^53-1 as per Section 1.3 *) type unsigned_int = int (** Date string in RFC3339 format as per Section 1.4 *) type date = string (** UTCDate is a Date with 'Z' time zone as per Section 1.4 *) type utc_date = string (** Error object as per Section 3.6.2 *) type error = { type_: string; description: string option; } (** Set error object as per Section 5.3 *) type set_error = { type_: string; description: string option; properties: string list option; (* Additional properties for specific error types *) existing_id: id option; (* For alreadyExists error *) } (** Invocation object as per Section 3.2 *) type 'a invocation = { name: string; arguments: 'a; method_call_id: string; } (** ResultReference object as per Section 3.7 *) type result_reference = { result_of: string; name: string; path: string; } (** FilterOperator, FilterCondition and Filter as per Section 5.5 *) type filter_operator = { operator: string; (* "AND", "OR", "NOT" *) conditions: filter list; } and filter_condition = (string * Ezjsonm.value) list and filter = | Operator of filter_operator | Condition of filter_condition (** Comparator object for sorting as per Section 5.5 *) type comparator = { property: string; is_ascending: bool option; (* Optional, defaults to true *) collation: string option; (* Optional, server-dependent default *) } (** PatchObject as per Section 5.3 *) type patch_object = (string * Ezjsonm.value) list (** AddedItem structure as per Section 5.6 *) type added_item = { id: id; index: unsigned_int; } (** Account object as per Section 1.6.2 *) type account = { name: string; is_personal: bool; is_read_only: bool; account_capabilities: (string * Ezjsonm.value) list; } (** Core capability object as per Section 2 *) type core_capability = { max_size_upload: unsigned_int; max_concurrent_upload: unsigned_int; max_size_request: unsigned_int; max_concurrent_requests: unsigned_int; max_calls_in_request: unsigned_int; max_objects_in_get: unsigned_int; max_objects_in_set: unsigned_int; collation_algorithms: string list; } (** PushSubscription keys object as per Section 7.2 *) type push_keys = { p256dh: string; auth: string; } (** Session object as per Section 2 *) type session = { capabilities: (string * Ezjsonm.value) list; accounts: (id * account) list; primary_accounts: (string * id) list; username: string; api_url: string; download_url: string; upload_url: string; event_source_url: string option; state: string; } (** TypeState for state changes as per Section 7.1 *) type type_state = (string * string) list (** StateChange object as per Section 7.1 *) type state_change = { changed: (id * type_state) list; } (** PushVerification object as per Section 7.2.2 *) type push_verification = { push_subscription_id: id; verification_code: string; } (** PushSubscription object as per Section 7.2 *) type push_subscription = { id: id; device_client_id: string; url: string; keys: push_keys option; verification_code: string option; expires: utc_date option; types: string list option; } (** Request object as per Section 3.3 *) type request = { using: string list; method_calls: Ezjsonm.value invocation list; created_ids: (id * id) list option; } (** Response object as per Section 3.4 *) type response = { method_responses: Ezjsonm.value invocation list; created_ids: (id * id) list option; session_state: string; } (** Standard method arguments and responses *) (** Arguments for Foo/get method as per Section 5.1 *) type 'a get_arguments = { account_id: id; ids: id list option; properties: string list option; } (** Response for Foo/get method as per Section 5.1 *) type 'a get_response = { account_id: id; state: string; list: 'a list; not_found: id list; } (** Arguments for Foo/changes method as per Section 5.2 *) type changes_arguments = { account_id: id; since_state: string; max_changes: unsigned_int option; } (** Response for Foo/changes method as per Section 5.2 *) type changes_response = { account_id: id; old_state: string; new_state: string; has_more_changes: bool; created: id list; updated: id list; destroyed: id list; } (** Arguments for Foo/set method as per Section 5.3 *) type 'a set_arguments = { account_id: id; if_in_state: string option; create: (id * 'a) list option; update: (id * patch_object) list option; destroy: id list option; } (** Response for Foo/set method as per Section 5.3 *) type 'a set_response = { account_id: id; old_state: string option; new_state: string; created: (id * 'a) list option; updated: (id * 'a option) list option; destroyed: id list option; not_created: (id * set_error) list option; not_updated: (id * set_error) list option; not_destroyed: (id * set_error) list option; } (** Arguments for Foo/copy method as per Section 5.4 *) type 'a copy_arguments = { from_account_id: id; if_from_in_state: string option; account_id: id; if_in_state: string option; create: (id * 'a) list; on_success_destroy_original: bool option; destroy_from_if_in_state: string option; } (** Response for Foo/copy method as per Section 5.4 *) type 'a copy_response = { from_account_id: id; account_id: id; old_state: string option; new_state: string; created: (id * 'a) list option; not_created: (id * set_error) list option; } (** Arguments for Foo/query method as per Section 5.5 *) type query_arguments = { account_id: id; filter: filter option; sort: comparator list option; position: int_t option; anchor: id option; anchor_offset: int_t option; limit: unsigned_int option; calculate_total: bool option; } (** Response for Foo/query method as per Section 5.5 *) type query_response = { account_id: id; query_state: string; can_calculate_changes: bool; position: unsigned_int; ids: id list; total: unsigned_int option; limit: unsigned_int option; } (** Arguments for Foo/queryChanges method as per Section 5.6 *) type query_changes_arguments = { account_id: id; filter: filter option; sort: comparator list option; since_query_state: string; max_changes: unsigned_int option; up_to_id: id option; calculate_total: bool option; } (** Response for Foo/queryChanges method as per Section 5.6 *) type query_changes_response = { account_id: id; old_query_state: string; new_query_state: string; total: unsigned_int option; removed: id list; added: added_item list option; } (** Arguments for Blob/copy method as per Section 6.3 *) type blob_copy_arguments = { from_account_id: id; account_id: id; blob_ids: id list; } (** Response for Blob/copy method as per Section 6.3 *) type blob_copy_response = { from_account_id: id; account_id: id; copied: (id * id) list option; not_copied: (id * set_error) list option; } (** Upload response as per Section 6.1 *) type upload_response = { account_id: id; blob_id: id; type_: string; size: unsigned_int; } (** Problem details object as per RFC7807 and Section 3.6.1 *) type problem_details = { type_: string; status: int option; detail: string option; limit: string option; (* For "limit" error *) } end (** Module for working with ResultReferences as described in Section 3.7 of RFC8620 *) module ResultReference = struct open Types (** Create a reference to a previous method result *) let create ~result_of ~name ~path = { result_of; name; path } (** Create a JSON pointer path to access a specific property *) let property_path property = "/" ^ property (** Create a JSON pointer path to access all items in an array with a specific property *) let array_items_path ?(property="") array_property = let base = "/" ^ array_property ^ "/*" in if property = "" then base else base ^ "/" ^ property (** Create argument with result reference. Returns string key prefixed with # and ResultReference value. *) let reference_arg arg_name ref_obj = (* Prefix argument name with # *) let prefixed_name = "#" ^ arg_name in (* Convert reference object to JSON *) let json_value = `O [ ("resultOf", `String ref_obj.result_of); ("name", `String ref_obj.name); ("path", `String ref_obj.path) ] in (prefixed_name, json_value) (** Create a reference to all IDs returned by a query method *) let query_ids ~result_of = create ~result_of ~name:"Foo/query" ~path:"/ids" (** Create a reference to properties of objects returned by a get method *) let get_property ~result_of ~property = create ~result_of ~name:"Foo/get" ~path:("/list/*/" ^ property) end module Api = struct open Lwt.Syntax open Types (** Error that may occur during API requests *) type error = | Connection_error of string | HTTP_error of int * string | Parse_error of string | Authentication_error (** Result type for API operations *) type 'a result = ('a, error) Stdlib.result (** Configuration for a JMAP API client *) type config = { api_uri: Uri.t; username: string; authentication_token: string; } (** Convert Ezjsonm.value to string *) let json_to_string json = Ezjsonm.value_to_string ~minify:false json (** Parse response string as JSON value *) let parse_json_string str = try Ok (Ezjsonm.from_string str) with e -> Error (Parse_error (Printexc.to_string e)) (** Parse JSON response as a JMAP response object *) let parse_response json = try let method_responses = match Ezjsonm.find json ["methodResponses"] with | `A items -> List.map (fun json -> match json with | `A [`String name; args; `String method_call_id] -> { name; arguments = args; method_call_id } | _ -> raise (Invalid_argument "Invalid invocation format in response") ) items | _ -> raise (Invalid_argument "methodResponses is not an array") in let created_ids_opt = try let obj = Ezjsonm.find json ["createdIds"] in match obj with | `O items -> Some (List.map (fun (k, v) -> match v with | `String id -> (k, id) | _ -> raise (Invalid_argument "createdIds value is not a string") ) items) | _ -> None with Not_found -> None in let session_state = match Ezjsonm.find json ["sessionState"] with | `String s -> s | _ -> raise (Invalid_argument "sessionState is not a string") in Ok { method_responses; created_ids = created_ids_opt; session_state } with | Not_found -> Error (Parse_error "Required field not found in response") | Invalid_argument msg -> Error (Parse_error msg) | e -> Error (Parse_error (Printexc.to_string e)) (** Serialize a JMAP request object to JSON *) let serialize_request req = let method_calls_json = `A (List.map (fun (inv : 'a invocation) -> `A [`String inv.name; inv.arguments; `String inv.method_call_id] ) req.method_calls) in let using_json = `A (List.map (fun s -> `String s) req.using) in let json = `O [ ("using", using_json); ("methodCalls", method_calls_json) ] in let json = match req.created_ids with | Some ids -> let created_ids_json = `O (List.map (fun (k, v) -> (k, `String v)) ids) in Ezjsonm.update json ["createdIds"] (Some created_ids_json) | None -> json in json_to_string json (** Make a raw HTTP request *) let make_http_request ~method_ ~headers ~body uri = let open Cohttp in let open Cohttp_lwt_unix in let headers = Header.add_list (Header.init ()) headers in (* Log request details at debug level *) let header_list = Cohttp.Header.to_list headers in let redacted_headers = redact_headers header_list in Logs.debug (fun m -> m "\n===== HTTP REQUEST =====\n\ URI: %s\n\ METHOD: %s\n\ HEADERS:\n%s\n\ BODY:\n%s\n\ ======================\n" (Uri.to_string uri) method_ (String.concat "\n" (List.map (fun (k, v) -> Printf.sprintf " %s: %s" k v) redacted_headers)) body); Lwt.catch (fun () -> let* resp, body = match method_ with | "GET" -> Client.get ~headers uri | "POST" -> Client.post ~headers ~body:(Cohttp_lwt.Body.of_string body) uri | _ -> failwith (Printf.sprintf "Unsupported HTTP method: %s" method_) in let* body_str = Cohttp_lwt.Body.to_string body in let status = Response.status resp |> Code.code_of_status in (* Log response details at debug level *) let header_list = Cohttp.Header.to_list (Response.headers resp) in let redacted_headers = redact_headers header_list in Logs.debug (fun m -> m "\n===== HTTP RESPONSE =====\n\ STATUS: %d\n\ HEADERS:\n%s\n\ BODY:\n%s\n\ ======================\n" status (String.concat "\n" (List.map (fun (k, v) -> Printf.sprintf " %s: %s" k v) redacted_headers)) body_str); if status >= 200 && status < 300 then Lwt.return (Ok body_str) else Lwt.return (Error (HTTP_error (status, body_str)))) (fun e -> let error_msg = Printexc.to_string e in Logs.err (fun m -> m "%s" error_msg); Lwt.return (Error (Connection_error error_msg))) (** Make a raw JMAP API request TODO:claude *) let make_request config req = let body = serialize_request req in (* Choose appropriate authorization header based on whether it's a bearer token or basic auth *) let auth_header = if String.length config.username > 0 then (* Standard username/password authentication *) "Basic " ^ Base64.encode_string (config.username ^ ":" ^ config.authentication_token) else (* API token (bearer authentication) *) "Bearer " ^ config.authentication_token in (* Log auth header at debug level with redaction *) let redacted_header = if String.length config.username > 0 then "Basic " ^ redact_token (Base64.encode_string (config.username ^ ":" ^ config.authentication_token)) else "Bearer " ^ redact_token config.authentication_token in Logs.debug (fun m -> m "Using authorization header: %s" redacted_header); let headers = [ ("Content-Type", "application/json"); ("Content-Length", string_of_int (String.length body)); ("Authorization", auth_header) ] in let* result = make_http_request ~method_:"POST" ~headers ~body config.api_uri in match result with | Ok response_body -> (match parse_json_string response_body with | Ok json -> Logs.debug (fun m -> m "Successfully parsed JSON response"); Lwt.return (parse_response json) | Error e -> let msg = match e with Parse_error m -> m | _ -> "unknown error" in Logs.err (fun m -> m "Failed to parse response: %s" msg); Lwt.return (Error e)) | Error e -> (match e with | Connection_error msg -> Logs.err (fun m -> m "Connection error: %s" msg) | HTTP_error (code, _) -> Logs.err (fun m -> m "HTTP error %d" code) | Parse_error msg -> Logs.err (fun m -> m "Parse error: %s" msg) | Authentication_error -> Logs.err (fun m -> m "Authentication error")); Lwt.return (Error e) (** Parse a JSON object as a Session object *) let parse_session_object json = try let capabilities = match Ezjsonm.find json ["capabilities"] with | `O items -> items | _ -> raise (Invalid_argument "capabilities is not an object") in let accounts = match Ezjsonm.find json ["accounts"] with | `O items -> List.map (fun (id, json) -> match json with | `O _ -> let name = Ezjsonm.get_string (Ezjsonm.find json ["name"]) in let is_personal = Ezjsonm.get_bool (Ezjsonm.find json ["isPersonal"]) in let is_read_only = Ezjsonm.get_bool (Ezjsonm.find json ["isReadOnly"]) in let account_capabilities = match Ezjsonm.find json ["accountCapabilities"] with | `O items -> items | _ -> raise (Invalid_argument "accountCapabilities is not an object") in (id, { name; is_personal; is_read_only; account_capabilities }) | _ -> raise (Invalid_argument "account value is not an object") ) items | _ -> raise (Invalid_argument "accounts is not an object") in let primary_accounts = match Ezjsonm.find_opt json ["primaryAccounts"] with | Some (`O items) -> List.map (fun (k, v) -> match v with | `String id -> (k, id) | _ -> raise (Invalid_argument "primaryAccounts value is not a string") ) items | Some _ -> raise (Invalid_argument "primaryAccounts is not an object") | None -> [] in let username = Ezjsonm.get_string (Ezjsonm.find json ["username"]) in let api_url = Ezjsonm.get_string (Ezjsonm.find json ["apiUrl"]) in let download_url = Ezjsonm.get_string (Ezjsonm.find json ["downloadUrl"]) in let upload_url = Ezjsonm.get_string (Ezjsonm.find json ["uploadUrl"]) in let event_source_url = try Some (Ezjsonm.get_string (Ezjsonm.find json ["eventSourceUrl"])) with Not_found -> None in let state = Ezjsonm.get_string (Ezjsonm.find json ["state"]) in Ok { capabilities; accounts; primary_accounts; username; api_url; download_url; upload_url; event_source_url; state } with | Not_found -> Error (Parse_error "Required field not found in session object") | Invalid_argument msg -> Error (Parse_error msg) | e -> Error (Parse_error (Printexc.to_string e)) (** Fetch a Session object from a JMAP server TODO:claude *) let get_session uri ?username ?authentication_token ?api_token () = let headers = match (username, authentication_token, api_token) with | (Some u, Some t, _) -> let auth = "Basic " ^ Base64.encode_string (u ^ ":" ^ t) in let redacted_auth = "Basic " ^ redact_token (Base64.encode_string (u ^ ":" ^ t)) in Logs.info (fun m -> m "Session using Basic auth: %s" redacted_auth); [ ("Content-Type", "application/json"); ("Authorization", auth) ] | (_, _, Some token) -> let auth = "Bearer " ^ token in let redacted_token = redact_token token in Logs.info (fun m -> m "Session using Bearer auth: %s" ("Bearer " ^ redacted_token)); [ ("Content-Type", "application/json"); ("Authorization", auth) ] | _ -> [("Content-Type", "application/json")] in let* result = make_http_request ~method_:"GET" ~headers ~body:"" uri in match result with | Ok response_body -> (match parse_json_string response_body with | Ok json -> Logs.debug (fun m -> m "Successfully parsed session response"); Lwt.return (parse_session_object json) | Error e -> let msg = match e with Parse_error m -> m | _ -> "unknown error" in Logs.err (fun m -> m "Failed to parse session response: %s" msg); Lwt.return (Error e)) | Error e -> let err_msg = match e with | Connection_error msg -> "Connection error: " ^ msg | HTTP_error (code, _) -> Printf.sprintf "HTTP error %d" code | Parse_error msg -> "Parse error: " ^ msg | Authentication_error -> "Authentication error" in Logs.err (fun m -> m "Failed to get session: %s" err_msg); Lwt.return (Error e) (** Upload a binary blob to the server TODO:claude *) let upload_blob config ~account_id ~content_type data = let upload_url_template = config.api_uri |> Uri.to_string in (* Replace {accountId} with the actual account ID *) let upload_url = Str.global_replace (Str.regexp "{accountId}") account_id upload_url_template in let upload_uri = Uri.of_string upload_url in let headers = [ ("Content-Type", content_type); ("Content-Length", string_of_int (String.length data)); ("Authorization", "Basic " ^ Base64.encode_string (config.username ^ ":" ^ config.authentication_token)) ] in let* result = make_http_request ~method_:"POST" ~headers ~body:data upload_uri in match result with | Ok response_body -> (match parse_json_string response_body with | Ok json -> (try let account_id = Ezjsonm.get_string (Ezjsonm.find json ["accountId"]) in let blob_id = Ezjsonm.get_string (Ezjsonm.find json ["blobId"]) in let type_ = Ezjsonm.get_string (Ezjsonm.find json ["type"]) in let size = Ezjsonm.get_int (Ezjsonm.find json ["size"]) in Lwt.return (Ok { account_id; blob_id; type_; size }) with | Not_found -> Lwt.return (Error (Parse_error "Required field not found in upload response")) | e -> Lwt.return (Error (Parse_error (Printexc.to_string e)))) | Error e -> Lwt.return (Error e)) | Error e -> Lwt.return (Error e) (** Download a binary blob from the server TODO:claude *) let download_blob config ~account_id ~blob_id ?type_ ?name () = let download_url_template = config.api_uri |> Uri.to_string in (* Replace template variables with actual values *) let url = Str.global_replace (Str.regexp "{accountId}") account_id download_url_template in let url = Str.global_replace (Str.regexp "{blobId}") blob_id url in let url = match type_ with | Some t -> Str.global_replace (Str.regexp "{type}") (Uri.pct_encode t) url | None -> Str.global_replace (Str.regexp "{type}") "" url in let url = match name with | Some n -> Str.global_replace (Str.regexp "{name}") (Uri.pct_encode n) url | None -> Str.global_replace (Str.regexp "{name}") "file" url in let download_uri = Uri.of_string url in let headers = [ ("Authorization", "Basic " ^ Base64.encode_string (config.username ^ ":" ^ config.authentication_token)) ] in let* result = make_http_request ~method_:"GET" ~headers ~body:"" download_uri in Lwt.return result end