this repo has no description
1(* Unix-specific JMAP client implementation interface. *)
2
3open Jmap
4open Jmap.Types
5open Jmap.Error
6open Jmap.Session
7open Jmap.Wire
8
9(* Configuration options for a JMAP client context *)
10type client_config = {
11 connect_timeout : float option; (* Connection timeout in seconds *)
12 request_timeout : float option; (* Request timeout in seconds *)
13 max_concurrent_requests : int option; (* Maximum concurrent requests *)
14 max_request_size : int option; (* Maximum request size in bytes *)
15 user_agent : string option; (* User-Agent header value *)
16 authentication_header : string option; (* Custom Authentication header name *)
17}
18
19(* Authentication method options *)
20type auth_method =
21 | Basic of string * string (* Basic auth with username and password *)
22 | Bearer of string (* Bearer token auth *)
23 | Custom of (string * string) (* Custom header name and value *)
24 | Session_cookie of (string * string) (* Session cookie name and value *)
25 | No_auth (* No authentication *)
26
27(* The internal state of a JMAP client connection *)
28type context = {
29 config: client_config;
30 mutable session_url: Uri.t option;
31 mutable session: Session.t option;
32 mutable auth: auth_method;
33}
34
35(* Represents an active EventSource connection *)
36type event_source_connection = {
37 event_url: Uri.t;
38 mutable is_connected: bool;
39}
40
41(* A request builder for constructing and sending JMAP requests *)
42type request_builder = {
43 ctx: context;
44 mutable using: string list;
45 mutable method_calls: Invocation.t list;
46}
47
48(* Create default configuration options *)
49let default_config () = {
50 connect_timeout = Some 30.0;
51 request_timeout = Some 300.0;
52 max_concurrent_requests = Some 4;
53 max_request_size = Some (1024 * 1024 * 10); (* 10 MB *)
54 user_agent = Some "OCaml JMAP Unix Client/1.0";
55 authentication_header = None;
56}
57
58(* Create a client context with the specified configuration *)
59let create_client ?(config = default_config ()) () = {
60 config;
61 session_url = None;
62 session = None;
63 auth = No_auth;
64}
65
66(* Mock implementation for the Unix connection *)
67let connect ctx ?session_url ?username ~host ?port ?auth_method () =
68 (* In a real implementation, this would use Unix HTTP functions *)
69 let auth = match auth_method with
70 | Some auth -> auth
71 | None -> No_auth
72 in
73
74 (* Store the auth method for future requests *)
75 ctx.auth <- auth;
76
77 (* Set session URL, either directly or after discovery *)
78 let session_url = match session_url with
79 | Some url -> url
80 | None ->
81 (* In a real implementation, this would perform RFC 8620 discovery *)
82 let proto = "https" in
83 let host_with_port = match port with
84 | Some p -> host ^ ":" ^ string_of_int p
85 | None -> host
86 in
87 Uri.of_string (proto ^ "://" ^ host_with_port ^ "/.well-known/jmap")
88 in
89 ctx.session_url <- Some session_url;
90
91 (* Create a mock session object for this example *)
92 let caps = Hashtbl.create 4 in
93 Hashtbl.add caps Jmap.capability_core (`Assoc []);
94
95 let accounts = Hashtbl.create 1 in
96 let acct = Account.v
97 ~name:"user@example.com"
98 ~is_personal:true
99 ~is_read_only:false
100 ()
101 in
102 Hashtbl.add accounts "u1" acct;
103
104 let primary = Hashtbl.create 1 in
105 Hashtbl.add primary Jmap.capability_core "u1";
106
107 let api_url =
108 Uri.of_string ("https://" ^ host ^ "/api/jmap")
109 in
110
111 let session = Session.v
112 ~capabilities:caps
113 ~accounts
114 ~primary_accounts:primary
115 ~username:"user@example.com"
116 ~api_url
117 ~download_url:(Uri.of_string ("https://" ^ host ^ "/download/{accountId}/{blobId}"))
118 ~upload_url:(Uri.of_string ("https://" ^ host ^ "/upload/{accountId}"))
119 ~event_source_url:(Uri.of_string ("https://" ^ host ^ "/eventsource"))
120 ~state:"1"
121 ()
122 in
123
124 ctx.session <- Some session;
125 Ok (ctx, session)
126
127(* Create a request builder for constructing a JMAP request *)
128let build ctx = {
129 ctx;
130 using = [Jmap.capability_core]; (* Default to core capability *)
131 method_calls = [];
132}
133
134(* Set the using capabilities for a request *)
135let using builder capabilities =
136 { builder with using = capabilities }
137
138(* Add a method call to a request builder *)
139let add_method_call builder name args id =
140 let call = Invocation.v
141 ~method_name:name
142 ~arguments:args
143 ~method_call_id:id
144 ()
145 in
146 { builder with method_calls = builder.method_calls @ [call] }
147
148(* Create a reference to a previous method call result *)
149let create_reference result_of name =
150 Jmap.Wire.Result_reference.v
151 ~result_of
152 ~name
153 ~path:"" (* In a real implementation, this would include a JSON pointer *)
154 ()
155
156(* Execute a request and return the response *)
157let execute builder =
158 match builder.ctx.session with
159 | None -> Error (protocol_error "No active session")
160 | Some session ->
161 (* In a real implementation, this would create and send an HTTP request *)
162
163 (* Create a mock response for this implementation *)
164 let results = List.map (fun call ->
165 let method_name = Invocation.method_name call in
166 let call_id = Invocation.method_call_id call in
167 if method_name = "Core/echo" then
168 (* Echo method implementation *)
169 Ok call
170 else
171 (* For other methods, return a method error *)
172 Error (
173 Method_error.v
174 ~description:(Method_error_description.v
175 ~description:"Method not implemented in mock"
176 ())
177 `ServerUnavailable,
178 "Mock implementation"
179 )
180 ) builder.method_calls in
181
182 let resp = Response.v
183 ~method_responses:results
184 ~session_state:(session |> Session.state)
185 ()
186 in
187 Ok resp
188
189(* Perform a JMAP API request *)
190let request ctx req =
191 match ctx.session_url, ctx.session with
192 | None, _ -> Error (protocol_error "No session URL configured")
193 | _, None -> Error (protocol_error "No active session")
194 | Some url, Some session ->
195 (* In a real implementation, this would serialize the request and send it *)
196
197 (* Mock response implementation *)
198 let method_calls = Request.method_calls req in
199 let results = List.map (fun call ->
200 let method_name = Invocation.method_name call in
201 let call_id = Invocation.method_call_id call in
202 if method_name = "Core/echo" then
203 (* Echo method implementation *)
204 Ok call
205 else
206 (* For other methods, return a method error *)
207 Error (
208 Method_error.v
209 ~description:(Method_error_description.v
210 ~description:"Method not implemented in mock"
211 ())
212 `ServerUnavailable,
213 "Mock implementation"
214 )
215 ) method_calls in
216
217 let resp = Response.v
218 ~method_responses:results
219 ~session_state:(session |> Session.state)
220 ()
221 in
222 Ok resp
223
224(* Upload binary data *)
225let upload ctx ~account_id ~content_type ~data_stream =
226 match ctx.session with
227 | None -> Error (protocol_error "No active session")
228 | Some session ->
229 (* In a real implementation, would upload the data stream *)
230
231 (* Mock success response *)
232 let response = Jmap.Binary.Upload_response.v
233 ~account_id
234 ~blob_id:"b123456"
235 ~type_:content_type
236 ~size:1024 (* Mock size *)
237 ()
238 in
239 Ok response
240
241(* Download binary data *)
242let download ctx ~account_id ~blob_id ?content_type ?name =
243 match ctx.session with
244 | None -> Error (protocol_error "No active session")
245 | Some session ->
246 (* In a real implementation, would download the data and return a stream *)
247
248 (* Mock data stream - in real code, this would be read from the HTTP response *)
249 let mock_data = "This is mock downloaded data for blob " ^ blob_id in
250 let seq = Seq.cons mock_data Seq.empty in
251 Ok seq
252
253(* Copy blobs between accounts *)
254let copy_blobs ctx ~from_account_id ~account_id ~blob_ids =
255 match ctx.session with
256 | None -> Error (protocol_error "No active session")
257 | Some session ->
258 (* In a real implementation, would perform server-side copy *)
259
260 (* Mock success response with first blob copied and second failed *)
261 let copied = Hashtbl.create 1 in
262 Hashtbl.add copied (List.hd blob_ids) "b999999";
263
264 let response = Jmap.Binary.Blob_copy_response.v
265 ~from_account_id
266 ~account_id
267 ~copied
268 ()
269 in
270 Ok response
271
272(* Connect to the EventSource for push notifications *)
273let connect_event_source ctx ?types ?close_after ?ping =
274 match ctx.session with
275 | None -> Error (protocol_error "No active session")
276 | Some session ->
277 (* In a real implementation, would connect to EventSource URL *)
278
279 (* Create mock connection *)
280 let event_url = Session.event_source_url session in
281 let conn = { event_url; is_connected = true } in
282
283 (* Create a mock event sequence *)
284 let mock_state_change =
285 let changed = Hashtbl.create 1 in
286 let account_id = "u1" in
287 let state_map = Hashtbl.create 2 in
288 Hashtbl.add state_map "Email" "s123";
289 Hashtbl.add state_map "Mailbox" "s456";
290 Hashtbl.add changed account_id state_map;
291
292 Push.State_change.v ~changed ()
293 in
294
295 let ping_data =
296 Push.Event_source_ping_data.v ~interval:30 ()
297 in
298
299 (* Create a sequence with one state event and one ping event *)
300 let events = Seq.cons (`State mock_state_change)
301 (Seq.cons (`Ping ping_data) Seq.empty) in
302
303 Ok (conn, events)
304
305(* Create a websocket connection for JMAP over WebSocket *)
306let connect_websocket ctx =
307 match ctx.session with
308 | None -> Error (protocol_error "No active session")
309 | Some session ->
310 (* In a real implementation, would connect via WebSocket *)
311
312 (* Mock connection *)
313 let event_url = Session.api_url session in
314 let conn = { event_url; is_connected = true } in
315 Ok conn
316
317(* Send a message over a websocket connection *)
318let websocket_send conn req =
319 if not conn.is_connected then
320 Error (protocol_error "WebSocket not connected")
321 else
322 (* In a real implementation, would send over WebSocket *)
323
324 (* Mock response (same as request function) *)
325 let method_calls = Request.method_calls req in
326 let results = List.map (fun call ->
327 let method_name = Invocation.method_name call in
328 let call_id = Invocation.method_call_id call in
329 if method_name = "Core/echo" then
330 Ok call
331 else
332 Error (
333 Method_error.v
334 ~description:(Method_error_description.v
335 ~description:"Method not implemented in mock"
336 ())
337 `ServerUnavailable,
338 "Mock implementation"
339 )
340 ) method_calls in
341
342 let resp = Response.v
343 ~method_responses:results
344 ~session_state:"1"
345 ()
346 in
347 Ok resp
348
349(* Close an EventSource or WebSocket connection *)
350let close_connection conn =
351 if not conn.is_connected then
352 Error (protocol_error "Connection already closed")
353 else begin
354 conn.is_connected <- false;
355 Ok ()
356 end
357
358(* Close the JMAP connection context *)
359let close ctx =
360 ctx.session <- None;
361 ctx.session_url <- None;
362 Ok ()
363
364(* Helper functions for common tasks *)
365
366(* Helper to get a single object by ID *)
367let get_object ctx ~method_name ~account_id ~object_id ?properties =
368 let properties_param = match properties with
369 | Some props -> `List (List.map (fun p -> `String p) props)
370 | None -> `Null
371 in
372
373 let args = `Assoc [
374 ("accountId", `String account_id);
375 ("ids", `List [`String object_id]);
376 ("properties", properties_param);
377 ] in
378
379 let request_builder = build ctx
380 |> add_method_call method_name args "r1"
381 in
382
383 match execute request_builder with
384 | Error e -> Error e
385 | Ok response ->
386 (* Find the method response and extract the list with the object *)
387 match response |> Response.method_responses with
388 | [Ok invocation] when Invocation.method_name invocation = method_name ^ "/get" ->
389 let args = Invocation.arguments invocation in
390 begin match Yojson.Safe.Util.member "list" args with
391 | `List [obj] -> Ok obj
392 | _ -> Error (protocol_error "Object not found or invalid response")
393 end
394 | _ ->
395 Error (protocol_error "Method response not found")
396
397(* Helper to set up the connection with minimal options *)
398let quick_connect ~host ~username ~password =
399 let ctx = create_client () in
400 connect ctx ~host ~auth_method:(Basic(username, password)) ()
401
402(* Perform a Core/echo request to test connectivity *)
403let echo ctx ?data () =
404 let data = match data with
405 | Some d -> d
406 | None -> `Assoc [("hello", `String "world")]
407 in
408
409 let request_builder = build ctx
410 |> add_method_call "Core/echo" data "echo1"
411 in
412
413 match execute request_builder with
414 | Error e -> Error e
415 | Ok response ->
416 (* Find the Core/echo response and extract the echoed data *)
417 match response |> Response.method_responses with
418 | [Ok invocation] when Invocation.method_name invocation = "Core/echo" ->
419 Ok (Invocation.arguments invocation)
420 | _ ->
421 Error (protocol_error "Echo response not found")
422
423(* High-level email operations *)
424module Email = struct
425 open Jmap_email.Types
426
427 (* Get an email by ID *)
428 let get_email ctx ~account_id ~email_id ?properties () =
429 let props = match properties with
430 | Some p -> p
431 | None -> List.map email_property_to_string detailed_email_properties
432 in
433
434 match get_object ctx ~method_name:"Email/get" ~account_id ~object_id:email_id ~properties:props with
435 | Error e -> Error e
436 | Ok json ->
437 (* In a real implementation, would parse the JSON into an Email.t structure *)
438 let mock_email = Email.create
439 ~id:email_id
440 ~thread_id:"t12345"
441 ~mailbox_ids:(let h = Hashtbl.create 1 in Hashtbl.add h "inbox" true; h)
442 ~keywords:(Keywords.of_list [Keywords.Seen])
443 ~subject:"Mock Email Subject"
444 ~preview:"This is a mock email..."
445 ~from:[Email_address.v ~name:"Sender Name" ~email:"sender@example.com" ()]
446 ~to_:[Email_address.v ~email:"recipient@example.com" ()]
447 ()
448 in
449 Ok mock_email
450
451 (* Search for emails using a filter *)
452 let search_emails ctx ~account_id ~filter ?sort ?limit ?position ?properties () =
453 (* Create the query args *)
454 let args = `Assoc [
455 ("accountId", `String account_id);
456 ("filter", Jmap.Methods.Filter.to_json filter);
457 ("sort", match sort with
458 | Some s -> `List [] (* Would convert sort params *)
459 | None -> `List [`Assoc [("property", `String "receivedAt"); ("isAscending", `Bool false)]]);
460 ("limit", match limit with
461 | Some l -> `Int l
462 | None -> `Int 20);
463 ("position", match position with
464 | Some p -> `Int p
465 | None -> `Int 0);
466 ] in
467
468 let request_builder = build ctx
469 |> add_method_call "Email/query" args "q1"
470 in
471
472 (* If properties were provided, add a Email/get method call as well *)
473 let request_builder = match properties with
474 | Some _ ->
475 let get_args = `Assoc [
476 ("accountId", `String account_id);
477 ("#ids", `Assoc [
478 ("resultOf", `String "q1");
479 ("name", `String "Email/query");
480 ("path", `String "/ids")
481 ]);
482 ("properties", match properties with
483 | Some p -> `List (List.map (fun prop -> `String prop) p)
484 | None -> `Null);
485 ] in
486 add_method_call request_builder "Email/get" get_args "g1"
487 | None -> request_builder
488 in
489
490 match execute request_builder with
491 | Error e -> Error e
492 | Ok response ->
493 (* Find the query response and extract the IDs *)
494 match Response.method_responses response with
495 | [Ok q_inv; Ok g_inv]
496 when Invocation.method_name q_inv = "Email/query"
497 && Invocation.method_name g_inv = "Email/get" ->
498
499 (* Extract IDs from query response *)
500 let q_args = Invocation.arguments q_inv in
501 let ids = match Yojson.Safe.Util.member "ids" q_args with
502 | `List l -> List.map Yojson.Safe.Util.to_string l
503 | _ -> []
504 in
505
506 (* Extract emails from get response *)
507 let g_args = Invocation.arguments g_inv in
508 (* In a real implementation, would parse each email in the list *)
509 let emails = List.map (fun id ->
510 Email.create
511 ~id
512 ~thread_id:("t" ^ id)
513 ~subject:(Printf.sprintf "Mock Email %s" id)
514 ()
515 ) ids in
516
517 Ok (ids, Some emails)
518
519 | [Ok q_inv] when Invocation.method_name q_inv = "Email/query" ->
520 (* If only query was performed (no properties requested) *)
521 let q_args = Invocation.arguments q_inv in
522 let ids = match Yojson.Safe.Util.member "ids" q_args with
523 | `List l -> List.map Yojson.Safe.Util.to_string l
524 | _ -> []
525 in
526
527 Ok (ids, None)
528
529 | _ ->
530 Error (protocol_error "Query response not found")
531
532 (* Mark multiple emails with a keyword *)
533 let mark_emails ctx ~account_id ~email_ids ~keyword () =
534 (* Create the set args with a patch to add the keyword *)
535 let keyword_patch = Jmap_email.Keyword_ops.add_keyword_patch keyword in
536
537 (* Create patches map for each email *)
538 let update = Hashtbl.create (List.length email_ids) in
539 List.iter (fun id ->
540 Hashtbl.add update id keyword_patch
541 ) email_ids;
542
543 let args = `Assoc [
544 ("accountId", `String account_id);
545 ("update", `Assoc (
546 List.map (fun id ->
547 (id, `Assoc (List.map (fun (path, value) ->
548 (path, value)
549 ) keyword_patch))
550 ) email_ids
551 ));
552 ] in
553
554 let request_builder = build ctx
555 |> add_method_call "Email/set" args "s1"
556 in
557
558 match execute request_builder with
559 | Error e -> Error e
560 | Ok response ->
561 (* In a real implementation, would check for errors *)
562 Ok ()
563
564 (* Mark emails as seen/read *)
565 let mark_as_seen ctx ~account_id ~email_ids () =
566 mark_emails ctx ~account_id ~email_ids ~keyword:Keywords.Seen ()
567
568 (* Mark emails as unseen/unread *)
569 let mark_as_unseen ctx ~account_id ~email_ids () =
570 let keyword_patch = Jmap_email.Keyword_ops.mark_unseen_patch () in
571
572 (* Create patches map for each email *)
573 let update = Hashtbl.create (List.length email_ids) in
574 List.iter (fun id ->
575 Hashtbl.add update id keyword_patch
576 ) email_ids;
577
578 let args = `Assoc [
579 ("accountId", `String account_id);
580 ("update", `Assoc (
581 List.map (fun id ->
582 (id, `Assoc (List.map (fun (path, value) ->
583 (path, value)
584 ) keyword_patch))
585 ) email_ids
586 ));
587 ] in
588
589 let request_builder = build ctx
590 |> add_method_call "Email/set" args "s1"
591 in
592
593 match execute request_builder with
594 | Error e -> Error e
595 | Ok _response -> Ok ()
596
597 (* Move emails to a different mailbox *)
598 let move_emails ctx ~account_id ~email_ids ~mailbox_id ?remove_from_mailboxes () =
599 (* Create patch to add to destination mailbox *)
600 let add_patch = [("mailboxIds/" ^ mailbox_id, `Bool true)] in
601
602 (* If remove_from_mailboxes is specified, add patches to remove *)
603 let remove_patch = match remove_from_mailboxes with
604 | Some mailboxes ->
605 List.map (fun mbx -> ("mailboxIds/" ^ mbx, `Null)) mailboxes
606 | None -> []
607 in
608
609 (* Combine patches *)
610 let patches = add_patch @ remove_patch in
611
612 (* Create patches map for each email *)
613 let update = Hashtbl.create (List.length email_ids) in
614 List.iter (fun id ->
615 Hashtbl.add update id patches
616 ) email_ids;
617
618 let args = `Assoc [
619 ("accountId", `String account_id);
620 ("update", `Assoc (
621 List.map (fun id ->
622 (id, `Assoc (List.map (fun (path, value) ->
623 (path, value)
624 ) patches))
625 ) email_ids
626 ));
627 ] in
628
629 let request_builder = build ctx
630 |> add_method_call "Email/set" args "s1"
631 in
632
633 match execute request_builder with
634 | Error e -> Error e
635 | Ok _response -> Ok ()
636
637 (* Import an RFC822 message *)
638 let import_email ctx ~account_id ~rfc822 ~mailbox_ids ?keywords ?received_at () =
639 (* In a real implementation, would first upload the message as a blob *)
640 let mock_blob_id = "b9876" in
641
642 (* Create the Email/import call *)
643 let args = `Assoc [
644 ("accountId", `String account_id);
645 ("emails", `Assoc [
646 ("msg1", `Assoc [
647 ("blobId", `String mock_blob_id);
648 ("mailboxIds", `Assoc (
649 List.map (fun id -> (id, `Bool true)) mailbox_ids
650 ));
651 ("keywords", match keywords with
652 | Some kws ->
653 `Assoc (List.map (fun k ->
654 (Types.Keywords.to_string k, `Bool true)) kws)
655 | None -> `Null);
656 ("receivedAt", match received_at with
657 | Some d -> `String (string_of_float d) (* Would format as RFC3339 *)
658 | None -> `Null);
659 ])
660 ]);
661 ] in
662
663 let request_builder = build ctx
664 |> add_method_call "Email/import" args "i1"
665 in
666
667 match execute request_builder with
668 | Error e -> Error e
669 | Ok response ->
670 (* In a real implementation, would extract the created ID *)
671 Ok "e12345"
672end