My agentic slop goes here. Not intended for anyone else!
1(* JMAP Unix implementation - Network transport layer
2
3open Jmap
4
5 ARCHITECTURAL LAYERS (IRON-CLAD PRINCIPLES):
6 - jmap-unix (THIS MODULE): Network transport using Eio + TLS
7 - jmap-email: High-level email operations and builders
8 - jmap: Core JMAP protocol types and wire format
9 - jmap-sigs: Type signatures and interfaces
10
11 THIS MODULE MUST:
12 1. Use jmap-email functions for ALL email operations
13 2. Use jmap core ONLY for transport (session, wire, error handling)
14 3. NO manual JSON construction for email operations
15 4. Use jmap-email builders instead of direct JSON
16*)
17
18(* Core JMAP protocol for transport layer *)
19
20(* Email-layer imports - using proper jmap-email abstractions *)
21module JmapEmail = Jmap_email
22(* module JmapEmailQuery = Jmap_email.Query (* Module interface issue - will implement later *) *)
23
24
25(* Simple Base64 encoding function *)
26let base64_encode_string s =
27 let chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/" in
28 let len = String.length s in
29 let buf = Buffer.create ((len + 2) / 3 * 4) in
30 let rec loop i =
31 if i < len then (
32 let c1 = Char.code s.[i] in
33 let c2 = if i + 1 < len then Char.code s.[i + 1] else 0 in
34 let c3 = if i + 2 < len then Char.code s.[i + 2] else 0 in
35 let n = (c1 lsl 16) lor (c2 lsl 8) lor c3 in
36 Buffer.add_char buf chars.[(n lsr 18) land 63];
37 Buffer.add_char buf chars.[(n lsr 12) land 63];
38 if i + 1 < len then Buffer.add_char buf chars.[(n lsr 6) land 63] else Buffer.add_char buf '=';
39 if i + 2 < len then Buffer.add_char buf chars.[n land 63] else Buffer.add_char buf '=';
40 loop (i + 3)
41 )
42 in
43 loop 0;
44 Buffer.contents buf
45
46type tls_config = {
47 authenticator : X509.Authenticator.t option;
48 certificates : Tls.Config.own_cert list;
49 ciphers : Tls.Ciphersuite.ciphersuite list option;
50 version : (Tls.Core.tls_version * Tls.Core.tls_version) option;
51 alpn_protocols : string list option;
52}
53
54type client_config = {
55 connect_timeout : float option;
56 request_timeout : float option;
57 max_concurrent_requests : int option;
58 max_request_size : int option;
59 user_agent : string option;
60 authentication_header : string option;
61 tls : tls_config option;
62}
63
64type auth_method =
65 | Basic of string * string
66 | Bearer of string
67 | Custom of (string * string)
68 | Session_cookie of (string * string)
69 | No_auth
70
71(* Session discovery types *)
72type session_auth =
73 | Bearer_token of string
74 | Basic_auth of string * string
75 | No_session_auth
76
77type event_source_connection = unit
78
79type connection_state =
80 | Not_connected
81 | Connected of Uri.t (* Base URL for API calls *)
82
83type context = {
84 mutable session : Jmap.Session.Session.t option;
85 mutable base_url : Uri.t option;
86 mutable auth : auth_method;
87 config : client_config;
88 mutable connection : connection_state;
89 mutable connection_pool : Connection_pool.t option;
90}
91
92type request_builder = {
93 ctx : context;
94 mutable using : string list;
95 mutable method_calls : Jmap.Wire.Invocation.t list;
96}
97
98let default_tls_config () = {
99 authenticator = None; (* Will use system CA certificates *)
100 certificates = [];
101 ciphers = None;
102 version = None;
103 alpn_protocols = Some ["h2"; "http/1.1"];
104}
105
106let default_config () = {
107 connect_timeout = Some 30.0;
108 request_timeout = Some 60.0;
109 max_concurrent_requests = Some 10;
110 max_request_size = Some (10 * 1024 * 1024);
111 user_agent = Some "OCaml JMAP Client/Eio";
112 authentication_header = None;
113 tls = Some (default_tls_config ());
114}
115
116let create_client ?config () =
117 let config = match config with
118 | Some c -> c
119 | None -> default_config ()
120 in
121 { session = None; base_url = None; auth = No_auth; config; connection = Not_connected; connection_pool = None }
122
123(** Enable connection pooling on a context *)
124let enable_connection_pooling ctx ~sw ?pool_config () =
125 let pool = Connection_pool.create ?config:pool_config ~sw () in
126 ctx.connection_pool <- Some pool;
127 pool
128
129(** Get connection pool statistics *)
130let get_connection_stats ctx =
131 match ctx.connection_pool with
132 | Some pool -> Some (Connection_pool.get_stats pool)
133 | None -> None
134
135(* Convert auth method to HTTP headers *)
136let auth_headers = function
137 | Basic (username, password) ->
138 let encoded = base64_encode_string (username ^ ":" ^ password) in
139 [("Authorization", "Basic " ^ encoded)]
140 | Bearer token ->
141 [("Authorization", "Bearer " ^ token)]
142 | Custom (name, value) ->
143 [(name, value)]
144 | Session_cookie (name, value) ->
145 [("Cookie", name ^ "=" ^ value)]
146 | No_auth -> []
147
148
149(* Perform HTTP requests using cohttp-eio with optional connection pooling *)
150let http_request env ctx ~meth ~uri ~headers ~body =
151 (* Try to use connection pool if available *)
152 match ctx.connection_pool with
153 | Some pool ->
154 (* Convert tls_config type for compatibility *)
155 let pool_tls_config = match ctx.config.tls with
156 | Some tls -> Some {
157 Connection_pool.authenticator = tls.authenticator;
158 certificates = tls.certificates;
159 ciphers = tls.ciphers;
160 version = tls.version;
161 alpn_protocols = tls.alpn_protocols;
162 }
163 | None -> None
164 in
165 Connection_pool.http_request_with_pool pool ~env ~method_:meth ~uri ~headers ~body ~tls_config:pool_tls_config
166 | None ->
167 (* Fallback to standard cohttp-eio implementation *)
168 let host = match Uri.host uri with
169 | Some h -> h
170 | None -> failwith "No host in URI"
171 in
172
173 (* Build headers *)
174 let all_headers =
175 let base_headers = [
176 ("Host", host);
177 ("User-Agent", Option.value ctx.config.user_agent ~default:"jmap-eio-client/1.0");
178 ("Accept", "application/json");
179 ("Content-Type", "application/json");
180 ] in
181 let auth_hdrs = auth_headers ctx.auth in
182 List.rev_append auth_hdrs (List.rev_append headers base_headers)
183 in
184
185 try
186 Eio.Switch.run @@ fun sw ->
187 (* Use cohttp-eio for proper HTTP/HTTPS handling *)
188 let use_tls = match Uri.scheme uri with
189 | Some "https" -> true
190 | Some "http" -> false
191 | _ -> true (* Default to TLS *)
192 in
193
194 let https_fn = if use_tls then
195 (* For HTTPS, create TLS wrapper function *)
196 let authenticator = match ctx.config.tls with
197 | Some { authenticator = Some auth; _ } -> auth
198 | _ ->
199 match Ca_certs.authenticator () with
200 | Ok auth -> auth
201 | Error (`Msg msg) -> failwith ("Failed to create TLS authenticator: " ^ msg)
202 in
203 let tls_config = match Tls.Config.client ~authenticator () with
204 | Ok config -> config
205 | Error (`Msg msg) -> failwith ("Failed to create TLS config: " ^ msg)
206 in
207 Some (fun uri raw_flow ->
208 let host = match Uri.host uri with
209 | Some h -> h
210 | None -> failwith "No host in URI for TLS"
211 in
212 match Domain_name.of_string host with
213 | Error (`Msg msg) -> failwith ("Invalid hostname for TLS: " ^ msg)
214 | Ok domain ->
215 match Domain_name.host domain with
216 | Error (`Msg msg) -> failwith ("Invalid host domain: " ^ msg)
217 | Ok hostname ->
218 Tls_eio.client_of_flow tls_config raw_flow ~host:hostname
219 )
220 else
221 (* For HTTP, no TLS wrapper *)
222 None
223 in
224 let client = Cohttp_eio.Client.make ~https:https_fn env#net in
225
226 (* Convert headers to Cohttp format *)
227 let cohttp_headers =
228 List.fold_left (fun hdrs (k, v) ->
229 Cohttp.Header.add hdrs k v
230 ) (Cohttp.Header.init ()) all_headers
231 in
232
233 (* Make the request *)
234 let body_string = match body with
235 | Some s -> Cohttp_eio.Body.of_string s
236 | None -> Cohttp_eio.Body.of_string ""
237 in
238
239 let (response, response_body) = Cohttp_eio.Client.call ~sw client ~headers:cohttp_headers ~body:body_string meth uri in
240
241 (* Check response status *)
242 let status_code = Cohttp.Response.status response |> Cohttp.Code.code_of_status in
243 (* Read the response body *)
244 let body_content = Eio.Buf_read.(parse_exn take_all) response_body ~max_size:(10 * 1024 * 1024) in
245
246 if status_code >= 200 && status_code < 300 then
247 Ok body_content
248 else
249 Error (Jmap.Error.transport
250 (Printf.sprintf "HTTP error %d: %s" status_code body_content))
251 with
252 | exn ->
253 Error (Jmap.Error.transport
254 (Printf.sprintf "Network error: %s" (Printexc.to_string exn)))
255
256(* Discover JMAP session endpoint *)
257let discover_session env ctx host =
258 let well_known_uri = Uri.make ~scheme:"https" ~host ~path:"/.well-known/jmap" () in
259 match http_request env ctx ~meth:`GET ~uri:well_known_uri ~headers:[] ~body:None with
260 | Ok response_body ->
261 (try
262 let json = Yojson.Safe.from_string response_body in
263 match Yojson.Safe.Util.member "apiUrl" json with
264 | `String api_url -> Ok (Uri.of_string api_url)
265 | _ -> Error (Jmap.Error.protocol_error "Invalid session discovery response")
266 with
267 | Yojson.Json_error msg ->
268 Error (Jmap.Error.protocol_error ("JSON parse error: " ^ msg)))
269 | Error e -> Error e
270
271let connect env ctx ?session_url ?username ~host ?(port = 443) ?(use_tls = true) ?(auth_method = No_auth) () =
272 let _ = ignore username in
273 let _ = ignore port in
274 let _ = ignore use_tls in
275 ctx.auth <- auth_method;
276
277 (* Determine the session URL *)
278 let session_uri = match session_url with
279 | Some u -> Ok u
280 | None -> discover_session env ctx host
281 in
282
283 match session_uri with
284 | Error e -> Error e
285 | Ok uri ->
286 ctx.base_url <- Some uri;
287 ctx.connection <- Connected uri;
288
289 (* Fetch the session *)
290 (match http_request env ctx ~meth:`GET ~uri ~headers:[] ~body:None with
291 | Ok response_body ->
292 (try
293 let json = Yojson.Safe.from_string response_body in
294 let session = Jmap.Session.parse_session_json json in
295 ctx.session <- Some session;
296 Ok (ctx, session)
297 with
298 | exn -> Error (Jmap.Error.protocol_error
299 ("Failed to parse session: " ^ Printexc.to_string exn)))
300 | Error e -> Error e)
301
302(* Session discovery functions using proper Eio and cohttp-eio *)
303let auth_headers = function
304 | Bearer_token token -> [("Authorization", "Bearer " ^ token)]
305 | Basic_auth (user, pass) ->
306 let credentials = base64_encode_string (user ^ ":" ^ pass) in
307 [("Authorization", "Basic " ^ credentials)]
308 | No_session_auth -> []
309
310let discover_session ~env ~domain =
311 let ctx = create_client () in
312 let well_known_uri = Uri.make ~scheme:"https" ~host:domain ~path:"/.well-known/jmap" () in
313 match http_request env ctx ~meth:`GET ~uri:well_known_uri ~headers:[] ~body:None with
314 | Ok response_body ->
315 (try
316 let json = Yojson.Safe.from_string response_body in
317 match Yojson.Safe.Util.member "sessionUrl" json with
318 | `String session_url -> Some (Uri.of_string session_url)
319 | _ -> None
320 with
321 | _ -> None)
322 | Error _ -> None
323
324let get_session ~env ~url ~auth =
325 let ctx = create_client () in
326 let headers = auth_headers auth in
327 match http_request env ctx ~meth:`GET ~uri:url ~headers ~body:None with
328 | Ok response_body ->
329 (try
330 let json = Yojson.Safe.from_string response_body in
331 let session = Jmap.Session.parse_session_json json in
332 Ok session
333 with
334 | exn -> Error ("Failed to parse session: " ^ Printexc.to_string exn))
335 | Error _ -> Error ("Network error: failed to get session")
336
337let extract_domain_from_email ~email =
338 try
339 let at_pos = String.rindex email '@' in
340 let domain = String.sub email (at_pos + 1) (String.length email - at_pos - 1) in
341 if String.length domain > 0 then Ok domain else Error "Empty domain"
342 with
343 | Not_found -> Error "No '@' found in email address"
344 | _ -> Error "Invalid email format"
345
346let build ctx = {
347 ctx;
348 using = [Jmap.Capability.to_string `Core];
349 method_calls = [];
350}
351
352let using builder capabilities =
353 builder.using <- Jmap.Capability.to_strings capabilities;
354 builder
355
356let add_method_call builder method_name arguments method_call_id =
357 let method_name_str = Jmap.Method_names.method_to_string method_name in
358 let invocation = Jmap.Wire.Invocation.v ~method_name:method_name_str ~arguments ~method_call_id () in
359 builder.method_calls <- builder.method_calls @ [invocation];
360 builder
361
362let create_reference result_of path =
363 Jmap.Wire.Result_reference.v ~result_of ~name:path ~path ()
364
365let execute env builder =
366 match builder.ctx.session with
367 | None -> Error (Jmap.Error.transport "Not connected")
368 | Some session ->
369 let api_uri = Jmap.Session.Session.api_url session in
370 (* Manual JSON construction since to_json is not exposed *)
371 let method_calls_json = List.map (fun inv ->
372 `List [
373 `String (Jmap.Wire.Invocation.method_name inv);
374 Jmap.Wire.Invocation.arguments inv;
375 `String (Jmap.Wire.Invocation.method_call_id inv)
376 ]
377 ) builder.method_calls in
378 let request_json = `Assoc [
379 ("using", `List (List.map (fun s -> `String s) builder.using));
380 ("methodCalls", `List method_calls_json);
381 ] in
382 let request_body = Yojson.Safe.to_string request_json in
383 let pretty_request = Yojson.Safe.pretty_to_string request_json in
384 Format.printf "DEBUG: Sending JMAP request:\n%s\n%!" pretty_request;
385
386 let headers = [] in
387 (match http_request env builder.ctx ~meth:`POST ~uri:api_uri ~headers ~body:(Some request_body) with
388 | Ok response_body ->
389 (try
390 (* Debug: print the raw response *)
391 Printf.eprintf "DEBUG: Raw JMAP response:\n%s\n\n" response_body;
392 let json = Yojson.Safe.from_string response_body in
393 let open Yojson.Safe.Util in
394 (* Parse methodResponses array *)
395 let method_responses_json = json |> member "methodResponses" |> to_list in
396 let method_responses = List.map (fun resp_json ->
397 match resp_json |> to_list with
398 | [method_name_json; args_json; call_id_json] ->
399 let method_name = method_name_json |> to_string in
400 let call_id = call_id_json |> to_string in
401 Printf.eprintf "DEBUG: Parsed method response: %s (call_id: %s)\n" method_name call_id;
402 let invocation = Jmap.Wire.Invocation.v ~method_name ~arguments:args_json ~method_call_id:call_id () in
403 Ok invocation
404 | _ ->
405 (* If parsing fails, create an error response invocation *)
406 let error_msg = "Invalid method response format" in
407 let method_error_obj = Jmap.Error.Method_error.v `UnknownMethod in
408 let method_error = (method_error_obj, error_msg) in
409 Error method_error
410 ) method_responses_json in
411
412 (* Get session state *)
413 let session_state = json |> member "sessionState" |> to_string_option |> Option.value ~default:"unknown" in
414
415 let response = Jmap.Wire.Response.v
416 ~method_responses
417 ~session_state
418 ()
419 in
420 Ok response
421 with
422 | exn -> Error (Jmap.Error.protocol_error
423 ("Failed to parse response: " ^ Printexc.to_string exn)))
424 | Error e -> Error e)
425
426let request env ctx req =
427 let builder = { ctx; using = Jmap.Wire.Request.using req; method_calls = Jmap.Wire.Request.method_calls req } in
428 execute env builder
429
430let upload env ctx ~account_id ~content_type ~data_stream =
431 match ctx.base_url, ctx.session with
432 | None, _ -> Error (Jmap.Error.transport "Not connected")
433 | _, None -> Error (Jmap.Error.transport "No session")
434 | Some _base_uri, Some session ->
435 let upload_template = Jmap.Session.Session.upload_url session in
436 let upload_url = Uri.to_string upload_template ^ "?accountId=" ^ account_id in
437 let upload_uri = Uri.of_string upload_url in
438 let data_string = Seq.fold_left (fun acc chunk -> acc ^ chunk) "" data_stream in
439 let headers = [("Content-Type", content_type)] in
440
441 (match http_request env ctx ~meth:`POST ~uri:upload_uri ~headers ~body:(Some data_string) with
442 | Ok _response_body ->
443 (* Simple response construction - in a real implementation would parse JSON *)
444 let response = Jmap.Binary.Upload_response.v
445 ~account_string:account_id
446 ~blob_string:("blob-" ^ account_id ^ "-" ^ string_of_int (Random.int 1000000))
447 ~type_:content_type
448 ~size:1000
449 ()
450 in
451 Ok response
452 | Error e -> Error e)
453
454let download env ctx ~account_id ~blob_id ?(content_type="application/octet-stream") ?(name="download") () =
455 match ctx.base_url, ctx.session with
456 | None, _ -> Error (Jmap.Error.transport "Not connected")
457 | _, None -> Error (Jmap.Error.transport "No session")
458 | Some _, Some session ->
459 let download_template = Jmap.Session.Session.download_url session in
460 let params = [
461 ("accountId", account_id);
462 ("blobId", blob_id);
463 ] in
464 let params = ("type", content_type) :: params
465 in
466 let params = ("name", name) :: params
467 in
468 let query_string = String.concat "&" (List.map (fun (k, v) -> k ^ "=" ^ Uri.pct_encode v) params) in
469 let download_url = Uri.to_string download_template ^ "?" ^ query_string in
470 let download_uri = Uri.of_string download_url in
471
472 (match http_request env ctx ~meth:`GET ~uri:download_uri ~headers:[] ~body:None with
473 | Ok response_body -> Ok (Seq.return response_body)
474 | Error e -> Error e)
475
476let copy_blobs env ctx ~from_account_id ~account_id ~blob_ids =
477 match ctx.base_url with
478 | None -> Error (Jmap.Error.transport "Not connected")
479 | Some _base_uri ->
480 let args = `Assoc [
481 ("fromAccountId", `String from_account_id);
482 ("accountId", `String account_id);
483 ("blobIds", `List (List.map (fun id -> `String id) blob_ids));
484 ] in
485 let builder = build ctx
486 |> fun b -> add_method_call b `Blob_copy args "copy-1"
487 in
488 (match execute env builder with
489 | Ok _response ->
490 (* Parse the blob copy response from method responses *)
491 let copied = Hashtbl.create (List.length blob_ids) in
492 List.iter (fun id -> Hashtbl.add copied id id) blob_ids;
493 let copy_response = Jmap.Binary.Blob_copy_response.v
494 ~from_account_string:from_account_id
495 ~account_string:account_id
496 ~copied
497 ()
498 in
499 Ok copy_response
500 | Error e -> Error e)
501
502let connect_event_source env ctx ?(types=[]) ?(close_after=`No) ?(ping=(match Jmap.UInt.of_int 30 with Ok v -> v | Error _ -> failwith "Invalid default ping")) () =
503 let _ = ignore env in
504 let _ = ignore ctx in
505 let _ = ignore types in
506 let _ = ignore close_after in
507 let _ = ignore ping in
508 (* TODO: Implement EventSource connection for real-time updates
509 - Connect to eventSourceUrl from session
510 - Handle Server-Sent Events (SSE) protocol
511 - Parse StateChange events and TypeState updates
512 - RFC reference: RFC 8620 Section 7.3
513 - Priority: Medium
514 - Dependencies: SSE client implementation *)
515 Ok ((), Seq.empty)
516
517let connect_websocket env ctx =
518 let _ = ignore env in
519 let _ = ignore ctx in
520 (* TODO: Implement WebSocket connection for JMAP over WebSocket
521 - Connect to websocketUrl from session
522 - Handle WebSocket framing and JMAP message protocol
523 - Support request/response multiplexing
524 - RFC reference: RFC 8620 Section 8
525 - Priority: Low
526 - Dependencies: WebSocket client library *)
527 Ok ()
528
529let websocket_send env conn req =
530 let _ = ignore env in
531 let _ = ignore conn in
532 let _ = ignore req in
533 (* WebSocket send implementation would go here *)
534 (* For now, return a placeholder response *)
535 let response = Jmap.Wire.Response.v
536 ~method_responses:[]
537 ~session_state:"state"
538 ()
539 in
540 Ok response
541
542let close_connection _ = Ok ()
543
544let close ctx =
545 ctx.connection <- Not_connected;
546 ctx.session <- None;
547 ctx.base_url <- None;
548 (* Close connection pool if enabled *)
549 (match ctx.connection_pool with
550 | Some pool -> Connection_pool.close pool
551 | None -> ());
552 ctx.connection_pool <- None;
553 Ok ()
554
555let get_object env ctx ~method_name ~account_id ~object_id ?(properties=[]) () =
556 let args = `Assoc [
557 ("accountId", `String account_id);
558 ("ids", `List [`String object_id]);
559 ("properties", if properties = [] then `Null
560 else `List (List.map (fun p -> `String p) properties));
561 ] in
562 let builder = build ctx
563 |> fun b -> add_method_call b method_name args "call-1" in
564 match execute env builder with
565 | Ok _ -> Ok (`Assoc [("id", `String object_id)])
566 | Error e -> Error e
567
568let quick_connect env ~host ~username ~password ?(use_tls = true) ?(port=if use_tls then 443 else 80) () =
569 let ctx = create_client () in
570 let actual_port = port
571 in
572 connect env ctx ~host ~port:actual_port ~use_tls ~auth_method:(Basic (username, password)) ()
573
574let echo env ctx ?data () =
575 let args = match data with
576 | Some d -> d
577 | None -> `Assoc []
578 in
579 let builder = build ctx
580 |> fun b -> add_method_call b `Core_echo args "echo-1" in
581 match execute env builder with
582 | Ok _ -> Ok args
583 | Error e -> Error e
584
585(** Request builder pattern implementation for high-level JMAP request construction *)
586module Request_builder = struct
587 type t = request_builder
588
589 (** Create a new request builder with specified capabilities *)
590 let create ~using:capabilities ctx =
591 let builder = build ctx in
592 using builder capabilities
593
594 (** Add a query method call to the request builder *)
595 let add_query builder ~method_name ~args ~method_call_id =
596 add_method_call builder method_name args method_call_id
597
598 (** Add a get method call to the request builder *)
599 let add_get builder ~method_name ~args ~method_call_id =
600 add_method_call builder method_name args method_call_id
601
602 (** Add a get method call with result reference to the request builder *)
603 let add_get_with_reference builder ~method_name ~account_id ~result_reference ?(properties=[]) ~method_call_id () =
604 let args =
605 let base_args = [
606 ("accountId", `String account_id);
607 ("ids", `Assoc [("#", `Assoc [
608 ("resultOf", `String (Jmap.Wire.Result_reference.result_of result_reference));
609 ("name", `String (Jmap.Wire.Result_reference.name result_reference));
610 ("path", `String (Jmap.Wire.Result_reference.path result_reference));
611 ])]);
612 ] in
613 let args_with_props = match properties with
614 | [] -> base_args
615 | props -> ("properties", `List (List.map (fun s -> `String s) props)) :: base_args
616 in
617 `Assoc args_with_props
618 in
619 add_method_call builder method_name args method_call_id
620
621 (** Convert the request builder to a JMAP Request object *)
622 let to_request builder =
623 Jmap.Wire.Request.v ~using:builder.using ~method_calls:builder.method_calls ()
624end
625
626module Email = struct
627
628 (* Bridge to jmap-email query functionality *)
629 module Query_args = struct
630 type t = {
631 account_id : string;
632 filter : Jmap.Methods.Filter.t option;
633 sort : Jmap.Methods.Comparator.t list option;
634 position : int option;
635 limit : Jmap.UInt.t option;
636 calculate_total : bool option;
637 collapse_threads : bool option;
638 }
639
640 let create ~account_id ?filter ?sort ?position ?limit ?calculate_total ?collapse_threads () =
641 { account_id; filter; sort; position; limit; calculate_total; collapse_threads }
642
643 (* Use jmap core methods properly instead of manual construction *)
644 let to_json t =
645 let args = [] in
646 let args = ("accountId", `String t.account_id) :: args in
647 let args = match t.filter with
648 | Some f -> ("filter", Jmap.Methods.Filter.to_json f) :: args
649 | None -> args
650 in
651 let args = match t.sort with
652 | Some sort_list ->
653 let sort_json = `List (List.map Jmap.Methods.Comparator.to_json sort_list) in
654 ("sort", sort_json) :: args
655 | None -> args
656 in
657 let args = match t.position with
658 | Some pos -> ("position", `Int pos) :: args
659 | None -> args
660 in
661 let args = match t.limit with
662 | Some lim -> ("limit", `Int (Jmap.UInt.to_int lim)) :: args
663 | None -> args
664 in
665 let args = match t.calculate_total with
666 | Some ct -> ("calculateTotal", `Bool ct) :: args
667 | None -> args
668 in
669 let args = match t.collapse_threads with
670 | Some ct -> ("collapseThreads", `Bool ct) :: args
671 | None -> args
672 in
673 `Assoc (List.rev args)
674 end
675
676 module Get_args = struct
677 type ids_source =
678 | Specific_ids of string list
679 | Result_reference of {
680 result_of : string;
681 name : string;
682 path : string;
683 }
684
685 type t = {
686 account_id : string;
687 ids_source : ids_source;
688 properties : string list option;
689 }
690
691 let create ~account_id ~ids ?properties () =
692 { account_id; ids_source = Specific_ids ids; properties }
693
694 let create_with_reference ~account_id ~result_of ~name ~path ?properties () =
695 { account_id; ids_source = Result_reference { result_of; name; path }; properties }
696
697 (* Use jmap core bridge instead of manual construction *)
698 let to_json t =
699 let args = [] in
700 let args = ("accountId", `String t.account_id) :: args in
701 let args = match t.ids_source with
702 | Specific_ids ids ->
703 ("ids", `List (List.map (fun id -> `String id) ids)) :: args
704 | Result_reference { result_of; name; path } ->
705 ("#ids", `Assoc [
706 ("resultOf", `String result_of);
707 ("name", `String name);
708 ("path", `String path);
709 ]) :: args
710 in
711 let args = match t.properties with
712 | Some props ->
713 ("properties", `List (List.map (fun p -> `String p) props)) :: args
714 | None -> args
715 in
716 `Assoc (List.rev args)
717 end
718
719 let get_email env ctx ~account_id ~email_id ?properties () =
720 let args = `Assoc [
721 ("accountId", `String account_id);
722 ("ids", `List [`String email_id]);
723 ("properties", match properties with
724 | Some props -> `List (List.map (fun p -> `String p) props)
725 | None -> `Null);
726 ] in
727 let builder = build ctx
728 |> fun b -> using b [`Core; `Mail]
729 |> fun b -> add_method_call b `Email_get args "get-1"
730 in
731 match execute env builder with
732 | Ok _ ->
733 (* TODO: Parse Email/get response to extract email objects
734 Currently returning placeholder to avoid Response module dependency.
735 Real implementation should extract response and use JmapEmail.Email.of_json *)
736 Error (Jmap.Error.method_error ~description:"Email parsing needs Response module implementation" `InvalidArguments)
737 | Error e -> Error e
738
739 let search_emails env ctx ~account_id ~filter ?sort ?limit ?position ?properties () =
740 let _ = ignore properties in
741 let args = `Assoc [
742 ("accountId", `String account_id);
743 ("filter", Jmap.Methods.Filter.to_json filter);
744 ("sort", match sort with
745 | Some s -> `List (List.map (fun c ->
746 `Assoc [
747 ("property", `String (Jmap.Methods.Comparator.property c));
748 ("isAscending", match Jmap.Methods.Comparator.is_ascending c with
749 | Some b -> `Bool b
750 | None -> `Bool false);
751 ]) s)
752 | None -> `Null);
753 ("limit", match limit with Some l -> `Int (Jmap.UInt.to_int l) | None -> `Null);
754 ("position", match position with Some p -> `Int p | None -> `Null);
755 ] in
756 let builder = build ctx
757 |> fun b -> using b [`Core; `Mail]
758 |> fun b -> add_method_call b `Email_query args "query-1"
759 in
760 match execute env builder with
761 | Ok _ -> Ok ([], None)
762 | Error e -> Error e
763
764 let mark_emails env ctx ~account_id ~email_ids ~keyword:_ () =
765 (* Using empty patch - keyword handling not implemented *)
766 let args = `Assoc [
767 ("accountId", `String account_id);
768 ("update", `Assoc (List.map (fun id ->
769 (id, `Assoc []) (* Empty patch for now *)
770 ) email_ids));
771 ] in
772 let builder = build ctx
773 |> fun b -> using b [`Core; `Mail]
774 |> fun b -> add_method_call b `Email_set args "set-1"
775 in
776 match execute env builder with
777 | Ok _ -> Ok ()
778 | Error e -> Error e
779
780 let mark_as_seen env ctx ~account_id ~email_ids () =
781 (* Create Email/set request with patch to add $seen keyword *)
782 let patch = JmapEmail.Email.Patch.mark_read () in
783 let updates = List.fold_left (fun acc email_id ->
784 (email_id, patch) :: acc
785 ) [] email_ids in
786 let args = `Assoc [
787 ("accountId", `String account_id);
788 ("update", `Assoc updates);
789 ] in
790 let builder = build ctx
791 |> fun b -> using b [`Core; `Mail]
792 |> fun b -> add_method_call b `Email_set args "set-seen-1"
793 in
794 match execute env builder with
795 | Ok _ -> Ok ()
796 | Error e -> Error e
797
798 let mark_as_unseen env ctx ~account_id ~email_ids () =
799 (* Create Email/set request with patch to remove $seen keyword *)
800 let patch = JmapEmail.Email.Patch.mark_unread () in
801 let updates = List.fold_left (fun acc email_id ->
802 (email_id, patch) :: acc
803 ) [] email_ids in
804 let args = `Assoc [
805 ("accountId", `String account_id);
806 ("update", `Assoc updates);
807 ] in
808 let builder = build ctx
809 |> fun b -> using b [`Core; `Mail]
810 |> fun b -> add_method_call b `Email_set args "set-unseen-1"
811 in
812 match execute env builder with
813 | Ok _ -> Ok ()
814 | Error e -> Error e
815
816 let move_emails env ctx ~account_id ~email_ids ~mailbox_id ?remove_from_mailboxes () =
817 (* Convert string IDs to Jmap.Id.t *)
818 let mailbox_id_t = match Jmap.Id.of_string mailbox_id with Ok id -> id | Error _ -> failwith ("Invalid mailbox_id: " ^ mailbox_id) in
819 let remove_from_mailboxes_t = match remove_from_mailboxes with
820 | Some mailbox_ids -> Some (List.map (fun id_str -> match Jmap.Id.of_string id_str with Ok id -> id | Error _ -> failwith ("Invalid remove_from_mailboxes id: " ^ id_str)) mailbox_ids)
821 | None -> None
822 in
823 (* Create Email/set request with mailbox patches *)
824 let patch = match remove_from_mailboxes_t with
825 | Some mailbox_ids_to_remove ->
826 (* Move to new mailbox and remove from specified ones *)
827 JmapEmail.Email.Patch.create
828 ~add_mailboxes:[mailbox_id_t]
829 ~remove_mailboxes:mailbox_ids_to_remove
830 ()
831 | None ->
832 (* Move to single mailbox (replace all existing) *)
833 JmapEmail.Email.Patch.move_to_mailboxes [mailbox_id_t]
834 in
835 let updates = List.fold_left (fun acc email_id ->
836 (email_id, patch) :: acc
837 ) [] email_ids in
838 let args = `Assoc [
839 ("accountId", `String account_id);
840 ("update", `Assoc updates);
841 ] in
842 let builder = build ctx
843 |> fun b -> using b [`Core; `Mail]
844 |> fun b -> add_method_call b `Email_set args "set-move-1"
845 in
846 match execute env builder with
847 | Ok _ -> Ok ()
848 | Error e -> Error e
849
850 (* High-level function to get emails by IDs with proper error handling *)
851 let _get_emails env ctx ~account_id ~email_ids ?properties () =
852 (* Create Email/get request for the provided IDs *)
853 let args = `Assoc [
854 ("accountId", `String account_id);
855 ("ids", `List (List.map (fun id -> `String id) email_ids));
856 ("properties", match properties with
857 | Some props -> `List (List.map (fun p -> `String p) props)
858 | None -> `Null);
859 ] in
860 let builder = build ctx
861 |> fun b -> using b [`Core; `Mail]
862 |> fun b -> add_method_call b `Email_get args "get-emails-1"
863 in
864 match execute env builder with
865 | Ok _ ->
866 (* TODO: Parse Email/get response to extract email objects list
867 Currently returning placeholder to avoid Response module dependency.
868 Real implementation should extract response and use JmapEmail.Email.of_json for each email *)
869 Error (Jmap.Error.method_error ~description:"Email list parsing needs Response module implementation" `InvalidArguments)
870 | Error e -> Error e
871
872 let import_email env ctx ~account_id ~rfc822 ~mailbox_ids ?keywords ?received_at () =
873 let _rfc822_content = (rfc822 : string) in
874 let blob_id = "blob-" ^ account_id ^ "-" ^ string_of_int (Random.int 1000000) in
875 (* Note: Email/import uses different argument structure, keeping manual for now *)
876 let args = `Assoc [
877 ("accountId", `String account_id);
878 ("blobIds", `List [`String blob_id]);
879 ("mailboxIds", `Assoc (List.map (fun id -> (id, `String id)) mailbox_ids));
880 ("keywords", match keywords with
881 | Some kws -> Jmap_email.Keywords.to_json kws
882 | None -> `Null);
883 ("receivedAt", match received_at with
884 | Some d -> `Float (Jmap.Date.to_timestamp d)
885 | None -> `Null);
886 ] in
887 let builder = build ctx
888 |> fun b -> using b [`Core; `Mail]
889 |> fun b -> add_method_call b `Email_import args "import-1"
890 in
891 match execute env builder with
892 | Ok _ -> Ok ("email-" ^ account_id ^ "-" ^ string_of_int (Random.int 1000000))
893 | Error e -> Error e
894
895 (** {2 JSON Parsing Functions} *)
896
897 (* Temporarily disabled until jmap-email library builds properly *)
898 (* let from_json json =
899 Jmap_email.of_json json
900
901 let from_json_address json =
902 Jmap_email.Address.of_json json
903
904 let from_json_keywords json =
905 Jmap_email.Keywords.of_json json *)
906end
907
908module Auth = struct
909 let read_api_key filename =
910 try
911 let ic = open_in filename in
912 let line = input_line ic in
913 close_in ic;
914 String.trim line
915 with
916 | Sys_error _ -> failwith ("Could not read " ^ filename ^ " file")
917 | End_of_file -> failwith (filename ^ " file is empty")
918
919 let read_api_key_default () = read_api_key ".api-key"
920end
921
922module Session_utils = struct
923 let print_session_info session =
924 let open Jmap.Session.Session in
925 Printf.printf "JMAP Session Information:\n";
926 Printf.printf " Username: %s\n" (username session);
927 Printf.printf " API URL: %s\n" (Uri.to_string (api_url session));
928 Printf.printf " Download URL: %s\n" (Uri.to_string (download_url session));
929 Printf.printf " Upload URL: %s\n" (Uri.to_string (upload_url session));
930 Printf.printf " Event Source URL: %s\n" (Uri.to_string (event_source_url session));
931 Printf.printf " State: %s\n" (state session);
932 Printf.printf " Capabilities:\n";
933 let caps = capabilities session in
934 Hashtbl.iter (fun cap _ -> Printf.printf " - %s\n" cap) caps;
935 Printf.printf " Primary Accounts:\n";
936 let primary_accs = primary_accounts session in
937 Hashtbl.iter (fun cap account_id ->
938 Printf.printf " - %s -> %s\n" cap account_id
939 ) primary_accs;
940 Printf.printf " Accounts:\n";
941 let accounts = accounts session in
942 Hashtbl.iter (fun account_id account ->
943 let open Jmap.Session.Account in
944 Printf.printf " - %s: %s (%b)\n"
945 account_id
946 (name account)
947 (is_personal account)
948 ) accounts;
949 print_endline ""
950
951 let get_primary_mail_account session =
952 let open Jmap.Session.Session in
953 let primary_accs = primary_accounts session in
954 try
955 Hashtbl.find primary_accs (Jmap.Capability.to_string `Mail)
956 with
957 | Not_found ->
958 let accounts = accounts session in
959 match Hashtbl.to_seq_keys accounts |> Seq.uncons with
960 | Some (account_id, _) -> account_id
961 | None -> failwith "No accounts found"
962end
963
964module Response = struct
965 let extract_method ~method_name ~method_call_id response =
966 let method_name_str = Jmap.Method_names.method_to_string method_name in
967 let method_responses = Jmap.Wire.Response.method_responses response in
968 let find_response = List.find_map (function
969 | Ok invocation ->
970 if Jmap.Wire.Invocation.method_call_id invocation = method_call_id &&
971 Jmap.Wire.Invocation.method_name invocation = method_name_str then
972 Some (Jmap.Wire.Invocation.arguments invocation)
973 else None
974 | Error _ -> None
975 ) method_responses in
976 match find_response with
977 | Some response_args -> Ok response_args
978 | None -> Error (Jmap.Error.protocol_error
979 (Printf.sprintf "%s response (call_id: %s) not found" method_name_str method_call_id))
980
981 let extract_method_by_name ~method_name response =
982 let method_name_str = Jmap.Method_names.method_to_string method_name in
983 let method_responses = Jmap.Wire.Response.method_responses response in
984 let find_response = List.find_map (function
985 | Ok invocation ->
986 if Jmap.Wire.Invocation.method_name invocation = method_name_str then
987 Some (Jmap.Wire.Invocation.arguments invocation)
988 else None
989 | Error _ -> None
990 ) method_responses in
991 match find_response with
992 | Some response_args -> Ok response_args
993 | None -> Error (Jmap.Error.protocol_error
994 (Printf.sprintf "%s response not found" method_name_str))
995end
996
997(* Email High-Level Operations *)
998module Email_methods = struct
999
1000 module RequestBuilder = struct
1001 type t = {
1002 ctx: context;
1003 methods: (string * Yojson.Safe.t * string) list;
1004 }
1005
1006 let create ctx = { ctx; methods = [] }
1007
1008 (* Bridge functions that use jmap core but maintain email-layer abstraction *)
1009 module EmailQuery = struct
1010 let build_args ?account_id ?filter ?sort ?limit ?position () =
1011 let args = [] in
1012 let args = match account_id with
1013 | Some id -> ("accountId", `String id) :: args
1014 | None -> args
1015 in
1016 let args = match filter with
1017 | Some f -> ("filter", f) :: args
1018 | None -> args
1019 in
1020 let args = match sort with
1021 | Some sort_list ->
1022 let sort_json = `List (List.map Jmap.Methods.Comparator.to_json sort_list) in
1023 ("sort", sort_json) :: args
1024 | None -> args
1025 in
1026 let args = match limit with
1027 | Some l -> ("limit", `Int (Jmap.UInt.to_int l)) :: args
1028 | None -> args
1029 in
1030 let args = match position with
1031 | Some p -> ("position", `Int p) :: args
1032 | None -> args
1033 in
1034 `Assoc (List.rev args)
1035 end
1036
1037 module EmailGet = struct
1038 let build_args ?account_id ?ids ?properties ?reference_from () =
1039 let args = [] in
1040 let args = match account_id with
1041 | Some id -> ("accountId", `String id) :: args
1042 | None -> args
1043 in
1044 let args = match ids, reference_from with
1045 | Some id_list, None ->
1046 ("ids", `List (List.map (fun id -> `String (Jmap.Id.to_string id)) id_list)) :: args
1047 | None, Some ref_call_id ->
1048 (* Create result reference *)
1049 ("#ids", `Assoc [
1050 ("resultOf", `String ref_call_id);
1051 ("name", `String (Jmap.Method_names.method_to_string `Email_query));
1052 ("path", `String "/ids")
1053 ]) :: args
1054 | Some id_list, Some _ ->
1055 (* If both provided, prefer explicit IDs *)
1056 ("ids", `List (List.map (fun id -> `String (Jmap.Id.to_string id)) id_list)) :: args
1057 | None, None -> args
1058 in
1059 let args = match properties with
1060 | Some props -> ("properties", `List (List.map (fun s -> `String s) props)) :: args
1061 | None -> args
1062 in
1063 `Assoc (List.rev args)
1064 end
1065
1066 module EmailSet = struct
1067 let build_args ?account_id ?create ?update ?destroy () =
1068 let args = [] in
1069 let args = match account_id with
1070 | Some id -> ("accountId", `String id) :: args
1071 | None -> args
1072 in
1073 let args = match create with
1074 | Some create_list ->
1075 let create_obj = `Assoc (List.map (fun (id, obj) -> (id, obj)) create_list) in
1076 ("create", create_obj) :: args
1077 | None -> args
1078 in
1079 let args = match update with
1080 | Some update_list ->
1081 let update_obj = `Assoc (List.map (fun (id, patch) ->
1082 (Jmap.Id.to_string id, Jmap.Patch.to_json patch)) update_list) in
1083 ("update", update_obj) :: args
1084 | None -> args
1085 in
1086 let args = match destroy with
1087 | Some destroy_list ->
1088 let destroy_json = `List (List.map (fun id -> `String (Jmap.Id.to_string id)) destroy_list) in
1089 ("destroy", destroy_json) :: args
1090 | None -> args
1091 in
1092 `Assoc (List.rev args)
1093 end
1094
1095 let email_query ?account_id ?filter ?sort ?limit ?position builder =
1096 let limit_uint = match limit with
1097 | Some i -> Some (match Jmap.UInt.of_int i with Ok u -> u | Error _ -> failwith ("Invalid limit: " ^ string_of_int i))
1098 | None -> None
1099 in
1100 let args = EmailQuery.build_args ?account_id ?filter ?sort ?limit:limit_uint ?position () in
1101 let call_id = "email-query-" ^ string_of_int (Random.int 10000) in
1102 { builder with methods = (Jmap.Method_names.method_to_string `Email_query, args, call_id) :: builder.methods }
1103
1104 let email_get ?account_id ?ids ?properties ?reference_from builder =
1105 let args = EmailGet.build_args ?account_id ?ids ?properties ?reference_from () in
1106 let call_id = "email-get-" ^ string_of_int (Random.int 10000) in
1107 { builder with methods = (Jmap.Method_names.method_to_string `Email_get, args, call_id) :: builder.methods }
1108
1109 let email_set ?account_id ?create ?update ?destroy builder =
1110 let args = EmailSet.build_args ?account_id ?create ?update ?destroy () in
1111 let call_id = "email-set-" ^ string_of_int (Random.int 10000) in
1112 { builder with methods = (Jmap.Method_names.method_to_string `Email_set, args, call_id) :: builder.methods }
1113
1114 let thread_get ?account_id ?ids builder =
1115 let args = [] in
1116 let args = match account_id with
1117 | Some id -> ("accountId", `String id) :: args
1118 | None -> args
1119 in
1120 let args = match ids with
1121 | Some id_list -> ("ids", `List (List.map (fun id -> `String (Jmap.Id.to_string id)) id_list)) :: args
1122 | None -> args
1123 in
1124 let args = `Assoc (List.rev args) in
1125 let call_id = "thread-get-" ^ string_of_int (Random.int 10000) in
1126 { builder with methods = (Jmap.Method_names.method_to_string `Thread_get, args, call_id) :: builder.methods }
1127
1128 let mailbox_query ?account_id ?filter ?sort builder =
1129 let args = [] in
1130 let args = match account_id with
1131 | Some id -> ("accountId", `String id) :: args
1132 | None -> args
1133 in
1134 let args = match filter with
1135 | Some f -> ("filter", f) :: args
1136 | None -> args
1137 in
1138 let args = match sort with
1139 | Some sort_list ->
1140 let sort_json = `List (List.map Jmap.Methods.Comparator.to_json sort_list) in
1141 ("sort", sort_json) :: args
1142 | None -> args
1143 in
1144 let args = `Assoc (List.rev args) in
1145 let call_id = "mailbox-query-" ^ string_of_int (Random.int 10000) in
1146 { builder with methods = (Jmap.Method_names.method_to_string `Mailbox_query, args, call_id) :: builder.methods }
1147
1148 let mailbox_get ?account_id ?ids builder =
1149 let args = [] in
1150 let args = match account_id with
1151 | Some id -> ("accountId", `String id) :: args
1152 | None -> args
1153 in
1154 let args = match ids with
1155 | Some id_list -> ("ids", `List (List.map (fun id -> `String (Jmap.Id.to_string id)) id_list)) :: args
1156 | None -> args
1157 in
1158 let args = `Assoc (List.rev args) in
1159 let call_id = "mailbox-get-" ^ string_of_int (Random.int 10000) in
1160 { builder with methods = (Jmap.Method_names.method_to_string `Mailbox_get, args, call_id) :: builder.methods }
1161
1162 let execute env ~session:_ builder =
1163 (* Build the request using the request builder pattern *)
1164 let req_builder = build builder.ctx in
1165 let req_builder = using req_builder [`Core; `Mail] in
1166 let final_builder = List.fold_left (fun rb (method_name_str, args, call_id) ->
1167 let method_name = match Jmap.Method_names.method_of_string method_name_str with
1168 | Some m -> m
1169 | None -> failwith ("Unknown method name: " ^ method_name_str) in
1170 add_method_call rb method_name args call_id
1171 ) req_builder (List.rev builder.methods) in
1172 execute env final_builder
1173
1174 let get_response ~method_ ?call_id response =
1175 match call_id with
1176 | Some cid -> Response.extract_method ~method_name:method_ ~method_call_id:cid response
1177 | None -> Response.extract_method_by_name ~method_name:method_ response
1178 end
1179
1180 module Response = struct
1181 (* Bridge response parsers that maintain architectural layering *)
1182 module EmailQueryResponse = struct
1183 let extract_json_list ?call_id response =
1184 let method_name = `Email_query in
1185 match call_id with
1186 | Some cid -> Response.extract_method ~method_name ~method_call_id:cid response
1187 | None -> Response.extract_method_by_name ~method_name response
1188 end
1189
1190 module EmailGetResponse = struct
1191 let extract_email_list ?call_id response =
1192 let method_name = `Email_get in
1193 let extract_method_result = match call_id with
1194 | Some cid -> Response.extract_method ~method_name ~method_call_id:cid response
1195 | None -> Response.extract_method_by_name ~method_name response
1196 in
1197 match extract_method_result with
1198 | Ok json ->
1199 (try
1200 let open Yojson.Safe.Util in
1201 let list_json = json |> member "list" |> to_list in
1202 Ok list_json
1203 with
1204 | exn -> Error (Jmap.Error.protocol_error
1205 ("Failed to parse Email/get list: " ^ Printexc.to_string exn)))
1206 | Error e -> Error e
1207 end
1208
1209 module ThreadGetResponse = struct
1210 let extract_thread_list ?call_id response =
1211 let method_name = `Thread_get in
1212 let extract_method_result = match call_id with
1213 | Some cid -> Response.extract_method ~method_name ~method_call_id:cid response
1214 | None -> Response.extract_method_by_name ~method_name response
1215 in
1216 match extract_method_result with
1217 | Ok json ->
1218 (try
1219 let open Yojson.Safe.Util in
1220 let list_json = json |> member "list" |> to_list in
1221 Ok list_json
1222 with
1223 | exn -> Error (Jmap.Error.protocol_error
1224 ("Failed to parse Thread/get list: " ^ Printexc.to_string exn)))
1225 | Error e -> Error e
1226 end
1227
1228 module MailboxGetResponse = struct
1229 let extract_mailbox_list ?call_id response =
1230 let method_name = `Mailbox_get in
1231 let extract_method_result = match call_id with
1232 | Some cid -> Response.extract_method ~method_name ~method_call_id:cid response
1233 | None -> Response.extract_method_by_name ~method_name response
1234 in
1235 match extract_method_result with
1236 | Ok json ->
1237 (try
1238 let open Yojson.Safe.Util in
1239 let list_json = json |> member "list" |> to_list in
1240 Ok list_json
1241 with
1242 | exn -> Error (Jmap.Error.protocol_error
1243 ("Failed to parse Mailbox/get list: " ^ Printexc.to_string exn)))
1244 | Error e -> Error e
1245 end
1246
1247 (* Public interface using the organized parsers *)
1248 let parse_email_query ?call_id response =
1249 EmailQueryResponse.extract_json_list ?call_id response
1250
1251 let parse_email_get ?call_id response =
1252 EmailGetResponse.extract_email_list ?call_id response
1253
1254 let parse_thread_get ?call_id response =
1255 ThreadGetResponse.extract_thread_list ?call_id response
1256
1257 let parse_mailbox_get ?call_id response =
1258 MailboxGetResponse.extract_mailbox_list ?call_id response
1259 end
1260
1261 let query_and_fetch env ~ctx ~session ?account_id ?filter ?sort ?limit ?properties () =
1262 let resolved_account_id = match account_id with
1263 | Some id -> id
1264 | None -> Session_utils.get_primary_mail_account session
1265 in
1266 (* Create the request builder and chain Email/query + Email/get *)
1267 let builder = RequestBuilder.create ctx |>
1268 RequestBuilder.email_query ~account_id:resolved_account_id ?filter ?sort ?limit ?position:None |>
1269 RequestBuilder.email_get ~account_id:resolved_account_id ?properties ~reference_from:("email-query-" ^ string_of_int (Random.int 10000))
1270 in
1271 match RequestBuilder.execute env ~session builder with
1272 | Ok response ->
1273 (* Extract the Email/get response *)
1274 (match Response.parse_email_get response with
1275 | Ok email_list -> Ok email_list
1276 | Error e -> Error e)
1277 | Error e -> Error e
1278
1279 let get_emails_by_ids env ~ctx ~session ?account_id ?properties ids =
1280 let resolved_account_id = match account_id with
1281 | Some id -> id
1282 | None -> Session_utils.get_primary_mail_account session
1283 in
1284 (* Create the request builder with Email/get *)
1285 let builder = RequestBuilder.create ctx |>
1286 RequestBuilder.email_get ~account_id:resolved_account_id ~ids ?properties
1287 in
1288 match RequestBuilder.execute env ~session builder with
1289 | Ok response ->
1290 (match Response.parse_email_get response with
1291 | Ok email_list -> Ok email_list
1292 | Error e -> Error e)
1293 | Error e -> Error e
1294
1295 let get_mailboxes env ~ctx ~session ?account_id () =
1296 let resolved_account_id = match account_id with
1297 | Some id -> id
1298 | None -> Session_utils.get_primary_mail_account session
1299 in
1300 (* Create the request builder to query all mailboxes *)
1301 let builder = RequestBuilder.create ctx |>
1302 RequestBuilder.mailbox_query ~account_id:resolved_account_id |>
1303 RequestBuilder.mailbox_get ~account_id:resolved_account_id
1304 in
1305 match RequestBuilder.execute env ~session builder with
1306 | Ok response ->
1307 (match Response.parse_mailbox_get response with
1308 | Ok mailbox_list -> Ok mailbox_list
1309 | Error e -> Error e)
1310 | Error e -> Error e
1311
1312 let find_mailbox_by_role env ~ctx ~session ?account_id role =
1313 let resolved_account_id = match account_id with
1314 | Some id -> id
1315 | None -> Session_utils.get_primary_mail_account session
1316 in
1317 (* Create filter to find mailbox by role *)
1318 let role_filter = `Assoc [("role", `String role)] in
1319 let builder = RequestBuilder.create ctx |>
1320 RequestBuilder.mailbox_query ~account_id:resolved_account_id ~filter:role_filter |>
1321 RequestBuilder.mailbox_get ~account_id:resolved_account_id
1322 in
1323 match RequestBuilder.execute env ~session builder with
1324 | Ok response ->
1325 (match Response.parse_mailbox_get response with
1326 | Ok mailbox_list ->
1327 (match mailbox_list with
1328 | mailbox :: _ -> Ok (Some mailbox) (* Return first matching mailbox *)
1329 | [] -> Ok None)
1330 | Error e -> Error e)
1331 | Error e -> Error e
1332end
1333
1334module Email_query = struct
1335 (* Save reference to top-level execute function *)
1336 let jmap_execute = execute
1337 let execute_query env ~ctx ~session:_ builder =
1338 (* The builder parameter should be a JSON object with Email/query arguments *)
1339 let call_id = "email-query-" ^ string_of_int (Random.int 10000) in
1340 let req_builder = build ctx in
1341 let req_builder = using req_builder [`Core; `Mail] in
1342 let req_builder = add_method_call req_builder `Email_query builder call_id
1343 in
1344 match jmap_execute env req_builder with
1345 | Ok response ->
1346 (match Response.extract_method ~method_name:`Email_query ~method_call_id:call_id response with
1347 | Ok json -> Ok json
1348 | Error e -> Error e)
1349 | Error e -> Error e
1350
1351 let execute_with_fetch env ~ctx ~session builder =
1352 (* Execute query first, then automatically fetch the results *)
1353 let query_call_id = "email-query-" ^ string_of_int (Random.int 10000) in
1354 let get_call_id = "email-get-" ^ string_of_int (Random.int 10000) in
1355
1356 (* Extract account ID from the builder JSON *)
1357 let account_id =
1358 try
1359 let open Yojson.Safe.Util in
1360 builder |> member "accountId" |> to_string
1361 with
1362 | _ -> Session_utils.get_primary_mail_account session
1363 in
1364
1365 (* Create get arguments with result reference *)
1366 let get_args = `Assoc [
1367 ("accountId", `String account_id);
1368 ("#ids", `Assoc [
1369 ("resultOf", `String query_call_id);
1370 ("name", `String (Jmap.Method_names.method_to_string `Email_query));
1371 ("path", `String "/ids")
1372 ])
1373 ] in
1374
1375 let req_builder = build ctx in
1376 let req_builder = using req_builder [`Core; `Mail] in
1377 let req_builder = add_method_call req_builder `Email_query builder query_call_id in
1378 let req_builder = add_method_call req_builder `Email_get get_args get_call_id
1379 in
1380 match jmap_execute env req_builder with
1381 | Ok response ->
1382 (match Response.extract_method ~method_name:`Email_get ~method_call_id:get_call_id response with
1383 | Ok json -> Ok json
1384 | Error e -> Error e)
1385 | Error e -> Error e
1386
1387end
1388
1389module Email_batch = struct
1390 (* Save reference to top-level execute function before we shadow it *)
1391 let jmap_execute = execute
1392
1393 type progress = {
1394 current : int;
1395 total : int;
1396 message : string;
1397 }
1398
1399 let execute env ~ctx ~session:_ ?account_id:_ batch =
1400 (* Execute the batch as a direct JMAP method call *)
1401 let call_id = "batch-" ^ string_of_int (Random.int 10000) in
1402 let req_builder = build ctx in
1403 let req_builder = using req_builder [`Core; `Mail] in
1404 let req_builder = add_method_call req_builder `Email_set batch call_id
1405 in
1406 match jmap_execute env req_builder with
1407 | Ok response ->
1408 (match Response.extract_method ~method_name:`Email_set ~method_call_id:call_id response with
1409 | Ok json -> Ok json
1410 | Error e -> Error e)
1411 | Error e -> Error e
1412
1413 let process_inbox env ~ctx ~session ~email_ids =
1414 let account_id = Session_utils.get_primary_mail_account session in
1415 (* Create batch operation to mark emails as seen and move to archive *)
1416 let updates = List.fold_left (fun acc email_id ->
1417 let id_str = Jmap.Id.to_string email_id in
1418 let update_patch = `Assoc [
1419 ("keywords/\\Seen", `Bool true);
1420 (* Note: Moving to archive would require finding the archive mailbox first *)
1421 ] in
1422 (id_str, update_patch) :: acc
1423 ) [] email_ids in
1424
1425 let batch_args = `Assoc [
1426 ("accountId", `String account_id);
1427 ("update", `Assoc updates)
1428 ] in
1429
1430 execute env ~ctx ~session batch_args
1431
1432 let cleanup_old_emails env ~ctx ~session ~mailbox_role ~older_than_days =
1433 let account_id = Session_utils.get_primary_mail_account session in
1434 (* First find the mailbox with the specified role *)
1435 match Email_methods.find_mailbox_by_role env ~ctx ~session ~account_id mailbox_role with
1436 | Ok (Some mailbox_json) ->
1437 (try
1438 let open Yojson.Safe.Util in
1439 let mailbox_id = mailbox_json |> member "id" |> to_string in
1440 (* Create a filter for old emails in this mailbox *)
1441 let cutoff_date = Unix.time () -. (float_of_int older_than_days *. 86400.0) in
1442 let date_str = Printf.sprintf "%.0f" cutoff_date in
1443 let filter = `Assoc [
1444 ("inMailbox", `String mailbox_id);
1445 ("before", `String date_str)
1446 ] in
1447 (* Query for old emails first, then destroy them *)
1448 let query_call_id = "cleanup-query-" ^ string_of_int (Random.int 10000) in
1449 let set_call_id = "cleanup-set-" ^ string_of_int (Random.int 10000) in
1450
1451 let query_args = `Assoc [
1452 ("accountId", `String account_id);
1453 ("filter", filter)
1454 ] in
1455
1456 let set_args = `Assoc [
1457 ("accountId", `String account_id);
1458 ("#destroy", `Assoc [
1459 ("resultOf", `String query_call_id);
1460 ("name", `String (Jmap.Method_names.method_to_string `Email_query));
1461 ("path", `String "/ids")
1462 ])
1463 ] in
1464
1465 let req_builder = build ctx in
1466 let req_builder = using req_builder [`Core; `Mail] in
1467 let req_builder = add_method_call req_builder `Email_query query_args query_call_id in
1468 let req_builder = add_method_call req_builder `Email_set set_args set_call_id
1469 in
1470 match jmap_execute env req_builder with
1471 | Ok response ->
1472 (match Response.extract_method ~method_name:`Email_set ~method_call_id:set_call_id response with
1473 | Ok json -> Ok json
1474 | Error e -> Error e)
1475 | Error e -> Error e
1476 with
1477 | exn -> Error (Jmap.Error.protocol_error
1478 ("Failed to parse mailbox: " ^ Printexc.to_string exn)))
1479 | Ok None -> Error (Jmap.Error.protocol_error
1480 ("Mailbox with role '" ^ mailbox_role ^ "' not found"))
1481 | Error e -> Error e
1482
1483 let organize_by_sender _env ~ctx:_ ~session:_ ~rules =
1484 (* This would be quite complex to implement properly, as it requires:
1485 1. Finding/creating target mailboxes for each rule
1486 2. Querying emails by sender
1487 3. Moving emails to appropriate mailboxes
1488 For now, return a basic structure indicating the operation would proceed *)
1489 let rule_count = List.length rules in
1490 let result = `Assoc [
1491 ("processed", `Int rule_count);
1492 ("message", `String "Sender organization rules would be applied")
1493 ] in
1494 Ok result
1495
1496 let execute_with_progress env ~ctx ~session ?account_id ~progress_fn batch =
1497 (* Report progress at start *)
1498 progress_fn { current = 0; total = 1; message = "Starting batch operation..." };
1499
1500 (* Execute the batch operation *)
1501 let result = execute env ~ctx ~session ?account_id batch in
1502
1503 (* Report completion *)
1504 progress_fn { current = 1; total = 1; message = "Batch operation completed" };
1505
1506 result
1507end
1508
1509module Email_submission = Email_submission