My agentic slop goes here. Not intended for anyone else!
1(** Email object implementation.
2
3 This module implements the main Email object type and operations as specified in
4 RFC 8621 Section 4.1. It provides comprehensive email handling with property-based
5 access, JSON serialization, and patch operations for modifications.
6
7 @see <https://www.rfc-editor.org/rfc/rfc8621.html#section-4.1> RFC 8621, Section 4.1
8*)
9
10[@@@warning "-32"] (* Suppress unused value warnings for interface-required functions *)
11
12(** Email field validation functions according to RFC 8621 *)
13module Validation = struct
14 (** Validate Message-ID format according to RFC 5322.
15 Message-ID must be enclosed in angle brackets and follow addr-spec rules
16 with restrictions: only dot-atom-text on left side, no CFWS allowed. *)
17 let is_valid_message_id (msg_id : string) : bool =
18 let len = String.length msg_id in
19 if len < 3 then false else
20 if msg_id.[0] != '<' || msg_id.[len-1] != '>' then false else
21 let content = String.sub msg_id 1 (len - 2) in
22 (* Check for required @ symbol *)
23 match String.index_opt content '@' with
24 | None -> false
25 | Some at_pos ->
26 if at_pos = 0 || at_pos = String.length content - 1 then false else
27 let local_part = String.sub content 0 at_pos in
28 let domain_part = String.sub content (at_pos + 1) (String.length content - at_pos - 1) in
29 (* Validate local part: only dot-atom-text allowed *)
30 let is_valid_dot_atom_char c =
31 (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') || (c >= '0' && c <= '9') ||
32 c = '!' || c = '#' || c = '$' || c = '%' || c = '&' || c = '\'' ||
33 c = '*' || c = '+' || c = '-' || c = '/' || c = '=' || c = '?' ||
34 c = '^' || c = '_' || c = '`' || c = '{' || c = '|' || c = '}' || c = '~'
35 in
36 let is_valid_local_part s =
37 if String.length s = 0 || s.[0] = '.' || s.[String.length s - 1] = '.' then false else
38 let has_consecutive_dots = ref false in
39 for i = 0 to String.length s - 2 do
40 if s.[i] = '.' && s.[i+1] = '.' then has_consecutive_dots := true
41 done;
42 if !has_consecutive_dots then false else
43 String.for_all (fun c -> c = '.' || is_valid_dot_atom_char c) s
44 in
45 let is_valid_domain s =
46 String.length s > 0 && String.for_all (fun c ->
47 (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') ||
48 (c >= '0' && c <= '9') || c = '.' || c = '-'
49 ) s && not (s.[0] = '.' || s.[String.length s - 1] = '.')
50 in
51 is_valid_local_part local_part && is_valid_domain domain_part
52
53 (** Validate keyword format according to RFC 8621 *)
54 let is_valid_keyword (keyword : string) : bool =
55 let len = String.length keyword in
56 if len = 0 || len > 255 then false else
57 let is_forbidden_char c =
58 c = '(' || c = ')' || c = '{' || c = ']' || c = '%' ||
59 c = '*' || c = '"' || c = '\\' || c <= ' ' || c > '~'
60 in
61 not (String.exists is_forbidden_char keyword) &&
62 String.for_all (fun c -> c >= '!' && c <= '~') keyword
63
64 (** Validate that all mailbox ID values are true according to RFC 8621 *)
65 let validate_mailbox_ids (mailbox_ids : (Jmap.Id.t, bool) Hashtbl.t) : (unit, string) result =
66 let all_true = Hashtbl.fold (fun _id value acc -> acc && value) mailbox_ids true in
67 if all_true then Ok () else Error "All mailboxIds values must be true"
68
69 (** Validate keywords hashtable according to RFC 8621 *)
70 let validate_keywords (keywords : (string, bool) Hashtbl.t) : (unit, string) result =
71 let errors = ref [] in
72 Hashtbl.iter (fun keyword value ->
73 if not value then
74 errors := (Printf.sprintf "Keyword '%s' value must be true" keyword) :: !errors;
75 if not (is_valid_keyword keyword) then
76 errors := (Printf.sprintf "Invalid keyword format: '%s'" keyword) :: !errors
77 ) keywords;
78 match !errors with
79 | [] -> Ok ()
80 | errs -> Error (String.concat "; " errs)
81
82 (** Validate message ID list with Message-ID format checking *)
83 let validate_message_id_list (msg_ids : string list option) : (unit, string) result =
84 match msg_ids with
85 | None -> Ok ()
86 | Some ids ->
87 let invalid_ids = List.filter (fun id -> not (is_valid_message_id id)) ids in
88 if invalid_ids = [] then Ok ()
89 else Error (Printf.sprintf "Invalid Message-ID format: %s" (String.concat ", " invalid_ids))
90
91 (** Validate email size constraints *)
92 let validate_size (size : Jmap.UInt.t option) : (unit, string) result =
93 match size with
94 | None -> Ok ()
95 | Some s ->
96 let size_val = Jmap.UInt.to_int s in
97 if size_val >= 0 then Ok ()
98 else Error "Email size must be non-negative"
99end
100
101(** JSON parsing combinators for cleaner field extraction *)
102module Json = struct
103 (** Extract a field from JSON object fields list *)
104 let field (name : string) (fields : (string * Yojson.Safe.t) list) : Yojson.Safe.t option =
105 List.assoc_opt name fields
106
107 (** Parse string field *)
108 let string (name : string) (fields : (string * Yojson.Safe.t) list) : string option =
109 match field name fields with
110 | Some (`String s) -> Some s
111 | _ -> None
112
113 (** Parse integer field *)
114 let int (name : string) (fields : (string * Yojson.Safe.t) list) : int option =
115 match field name fields with
116 | Some (`Int i) -> Some i
117 | _ -> None
118
119 (** Parse boolean field *)
120 let bool (name : string) (fields : (string * Yojson.Safe.t) list) : bool option =
121 match field name fields with
122 | Some (`Bool b) -> Some b
123 | _ -> None
124
125 (** Parse list field with element parser *)
126 let list (element_parser : Yojson.Safe.t -> 'a option) (name : string) (fields : (string * Yojson.Safe.t) list) : 'a list option =
127 match field name fields with
128 | Some (`List items) ->
129 let parsed = List.filter_map element_parser items in
130 if parsed <> [] then Some parsed else None
131 | _ -> None
132
133 (** Parse string list field *)
134 let string_list (name : string) (fields : (string * Yojson.Safe.t) list) : string list option =
135 list (function `String s -> Some s | _ -> None) name fields
136
137 (** Parse ISO 8601 Jmap.Date.t field to Unix timestamp *)
138 let iso_date (name : string) (fields : (string * Yojson.Safe.t) list) : float option =
139 match string name fields with
140 | Some s ->
141 (try
142 let tm = Scanf.sscanf s "%04d-%02d-%02dT%02d:%02d:%02dZ"
143 (fun y m d h min sec ->
144 {Unix.tm_year = y - 1900; tm_mon = m - 1; tm_mday = d;
145 tm_hour = h; tm_min = min; tm_sec = sec; tm_wday = 0;
146 tm_yday = 0; tm_isdst = false}) in
147 Some (fst (Unix.mktime tm))
148 with _ -> None)
149 | None -> None
150
151 (** Parse email address from JSON object *)
152 let email_address (json : Yojson.Safe.t) : Address.t option =
153 match json with
154 | `Assoc addr_fields ->
155 let email = string "email" addr_fields in
156 let name = string "name" addr_fields in
157 (match email with
158 | Some e when e <> "" -> Some (Address.create_unsafe ~email:e ?name ())
159 | _ -> None)
160 | _ -> None
161
162 (** Parse email address list field *)
163 let email_address_list (name : string) (fields : (string * Yojson.Safe.t) list) : Address.t list option =
164 list email_address name fields
165
166 (** Parse object field as hashtable *)
167 let object_map (value_parser : Yojson.Safe.t -> 'a option) (name : string) (fields : (string * Yojson.Safe.t) list) : (string, 'a) Hashtbl.t option =
168 match field name fields with
169 | Some (`Assoc obj_fields) ->
170 let tbl = Hashtbl.create (List.length obj_fields) in
171 let success = List.for_all (fun (key, value) ->
172 match value_parser value with
173 | Some parsed_value ->
174 Hashtbl.add tbl key parsed_value;
175 true
176 | None -> false
177 ) obj_fields in
178 if success && Hashtbl.length tbl > 0 then Some tbl else None
179 | _ -> None
180
181 (** Parse string-to-string map *)
182 let string_map (name : string) (fields : (string * Yojson.Safe.t) list) : (string, string) Hashtbl.t option =
183 object_map (function `String s -> Some s | _ -> None) name fields
184
185 (** Parse string-to-bool map (for mailboxIds) *)
186 let bool_map (name : string) (fields : (string * Yojson.Safe.t) list) : (string, bool) Hashtbl.t option =
187 object_map (function `Bool b -> Some b | _ -> None) name fields
188end
189
190type t = {
191 id : Jmap.Id.t option;
192 blob_id : Jmap.Id.t option;
193 thread_id : Jmap.Id.t option;
194 mailbox_ids : (Jmap.Id.t, bool) Hashtbl.t option;
195 keywords : Keywords.t option;
196 size : Jmap.UInt.t option;
197 received_at : Jmap.Date.t option;
198 message_id : string list option;
199 in_reply_to : string list option;
200 references : string list option;
201 sender : Address.t option;
202 from : Address.t list option;
203 to_ : Address.t list option;
204 cc : Address.t list option;
205 bcc : Address.t list option;
206 reply_to : Address.t list option;
207 subject : string option;
208 sent_at : Jmap.Date.t option;
209 has_attachment : bool option;
210 preview : string option;
211 body_structure : Body.t option;
212 body_values : (string, Body.Value.t) Hashtbl.t option;
213 text_body : Body.t list option;
214 html_body : Body.t list option;
215 attachments : Body.t list option;
216 headers : (string, string) Hashtbl.t option;
217 other_properties : (string, Yojson.Safe.t) Hashtbl.t;
218}
219
220(* Accessor functions *)
221let id t = t.id
222let blob_id t = t.blob_id
223let thread_id t = t.thread_id
224let mailbox_ids t = t.mailbox_ids
225let keywords t = t.keywords
226let size t = t.size
227let received_at t = t.received_at
228let message_id t = t.message_id
229let in_reply_to t = t.in_reply_to
230let references t = t.references
231let sender t = t.sender
232let from t = t.from
233let to_ t = t.to_
234let cc t = t.cc
235let bcc t = t.bcc
236let reply_to t = t.reply_to
237let subject t = t.subject
238let sent_at t = t.sent_at
239let has_attachment t = t.has_attachment
240let preview t = t.preview
241let body_structure t = t.body_structure
242let body_values t = t.body_values
243let text_body t = t.text_body
244let html_body t = t.html_body
245let attachments t = t.attachments
246
247let header t name =
248 match t.headers with
249 | Some headers -> Hashtbl.find_opt headers name
250 | None -> None
251
252(** Enhanced header access functions using structured parsing **)
253
254(** Get header as structured Header.t objects *)
255let headers_as_structured t : Header.t list =
256 match t.headers with
257 | Some headers ->
258 Hashtbl.fold (fun name value acc ->
259 let header = Header.create_unsafe ~name ~value () in
260 header :: acc
261 ) headers []
262 | None -> []
263
264(** Get specific header field as structured Header.t *)
265let get_header_field t name : Header.t option =
266 match t.headers with
267 | Some headers ->
268 (match Hashtbl.find_opt headers name with
269 | Some value -> Some (Header.create_unsafe ~name ~value ())
270 | None -> None)
271 | None -> None
272
273(** Get header using JMAP access patterns *)
274let get_header_as_text t name : string option =
275 match get_header_field t name with
276 | Some header -> Header.find_and_parse_as_text [header] name
277 | None -> None
278
279let get_header_as_addresses t name : Address.t list option =
280 match get_header_field t name with
281 | Some header -> Header.find_and_parse_as_addresses [header] name
282 | None -> None
283
284let get_header_as_message_ids t name : string list option =
285 match get_header_field t name with
286 | Some header -> Header.find_and_parse_as_message_ids [header] name
287 | None -> None
288
289let get_header_as_date t name : Jmap.Date.t option =
290 match get_header_field t name with
291 | Some header -> Header.find_and_parse_as_date [header] name
292 | None -> None
293
294(** Convenience functions for common header access patterns *)
295
296(** Get From header addresses using structured parsing *)
297let get_from_addresses t : Address.t list =
298 match get_header_as_addresses t "from" with
299 | Some addrs -> addrs
300 | None -> match t.from with Some addrs -> addrs | None -> []
301
302(** Get To header addresses using structured parsing *)
303let get_to_addresses t : Address.t list =
304 match get_header_as_addresses t "to" with
305 | Some addrs -> addrs
306 | None -> match t.to_ with Some addrs -> addrs | None -> []
307
308(** Get Subject header text using structured parsing *)
309let get_subject_text t : string option =
310 match get_header_as_text t "subject" with
311 | Some text -> Some text
312 | None -> t.subject
313
314(** Get Message-ID header *)
315let get_message_id t : string list =
316 match get_header_as_message_ids t "message-id" with
317 | Some ids -> ids
318 | None -> match t.message_id with Some ids -> ids | None -> []
319
320(** Get In-Reply-To header *)
321let get_in_reply_to t : string list =
322 match get_header_as_message_ids t "in-reply-to" with
323 | Some ids -> ids
324 | None -> match t.in_reply_to with Some ids -> ids | None -> []
325
326(** Get References header *)
327let get_references t : string list =
328 match get_header_as_message_ids t "references" with
329 | Some ids -> ids
330 | None -> match t.references with Some ids -> ids | None -> []
331
332(** Get Date header using structured parsing *)
333let get_date t : Jmap.Date.t option =
334 match get_header_as_date t "date" with
335 | Some date -> Some date
336 | None -> t.sent_at
337
338let other_properties t = t.other_properties
339
340(* JMAP_OBJECT signature implementations *)
341
342(* Create a minimal valid email object with only required fields *)
343let create ?id () =
344 {
345 id; blob_id = None; thread_id = None; mailbox_ids = None; keywords = None;
346 size = None; received_at = None; message_id = None; in_reply_to = None;
347 references = None; sender = None; from = None; to_ = None; cc = None;
348 bcc = None; reply_to = None; subject = None; sent_at = None;
349 has_attachment = None; preview = None; body_structure = None;
350 body_values = None; text_body = None; html_body = None; attachments = None;
351 headers = None; other_properties = Hashtbl.create 0;
352 }
353
354(* Get list of all valid property names for Email objects *)
355let valid_properties () = [
356 "id"; "blobId"; "threadId"; "mailboxIds"; "keywords"; "size"; "receivedAt";
357 "messageId"; "inReplyTo"; "references"; "sender"; "from"; "to"; "cc"; "bcc";
358 "replyTo"; "subject"; "sentAt"; "hasAttachment"; "preview"; "bodyStructure";
359 "bodyValues"; "textBody"; "htmlBody"; "attachments"; "headers"
360]
361
362(** Enhanced validation function for complete Email objects *)
363let validate (email : t) : (unit, string) result =
364 let errors = ref [] in
365
366 (* Validate mailbox_ids *)
367 (match email.mailbox_ids with
368 | Some mids ->
369 (match Validation.validate_mailbox_ids mids with
370 | Ok () -> ()
371 | Error msg -> errors := msg :: !errors)
372 | None -> ());
373
374 (* Validate size *)
375 (match Validation.validate_size email.size with
376 | Ok () -> ()
377 | Error msg -> errors := msg :: !errors);
378
379 (* Validate message ID fields *)
380 (match Validation.validate_message_id_list email.message_id with
381 | Ok () -> ()
382 | Error msg -> errors := ("messageId: " ^ msg) :: !errors);
383 (match Validation.validate_message_id_list email.in_reply_to with
384 | Ok () -> ()
385 | Error msg -> errors := ("inReplyTo: " ^ msg) :: !errors);
386 (match Validation.validate_message_id_list email.references with
387 | Ok () -> ()
388 | Error msg -> errors := ("references: " ^ msg) :: !errors);
389
390 match !errors with
391 | [] -> Ok ()
392 | errs -> Error (String.concat "; " errs)
393
394(* Serialize to JSON with only specified properties *)
395let to_json_with_properties ~properties t =
396 let all_fields = [
397 ("id", (match t.id with Some id_t -> `String (Jmap.Id.to_string id_t) | None -> `Null));
398 ("blobId", (match t.blob_id with Some id_t -> `String (Jmap.Id.to_string id_t) | None -> `Null));
399 ("threadId", (match t.thread_id with Some id_t -> `String (Jmap.Id.to_string id_t) | None -> `Null));
400 ("subject", (match t.subject with Some s -> `String s | None -> `Null));
401 ("size", (match t.size with Some i -> `Int i | None -> `Null));
402 (* Add more fields as needed - this is a simplified implementation *)
403 ] in
404 let filtered_fields = List.filter (fun (name, _) ->
405 List.mem name properties
406 ) all_fields in
407 let non_null_fields = List.filter (fun (_, value) ->
408 value <> `Null
409 ) filtered_fields in
410 `Assoc non_null_fields
411
412(* Extended create function with all properties *)
413let create_full ?id ?blob_id ?thread_id ?mailbox_ids ?keywords ?size ?received_at
414 ?message_id ?in_reply_to ?references ?sender ?from ?to_ ?cc ?bcc
415 ?reply_to ?subject ?sent_at ?has_attachment ?preview ?body_structure
416 ?body_values ?text_body ?html_body ?attachments ?headers
417 ?(other_properties = Hashtbl.create 0) () =
418 {
419 id; blob_id; thread_id; mailbox_ids; keywords; size; received_at;
420 message_id; in_reply_to; references; sender; from; to_; cc; bcc;
421 reply_to; subject; sent_at; has_attachment; preview; body_structure;
422 body_values; text_body; html_body; attachments; headers; other_properties;
423 }
424
425(** Get email ID with validation *)
426let get_id t =
427 match t.id with
428 | Some id -> Ok id
429 | None -> Error "Email object has no ID"
430
431(** Create email with validation *)
432let create_validated ?id ?blob_id ?thread_id ?mailbox_ids ?keywords ?size ?received_at
433 ?message_id ?in_reply_to ?references ?sender ?from ?to_ ?cc ?bcc
434 ?reply_to ?subject ?sent_at ?has_attachment ?preview ?body_structure
435 ?body_values ?text_body ?html_body ?attachments ?headers
436 ?(other_properties = Hashtbl.create 0) () =
437 let email = create_full ?id ?blob_id ?thread_id ?mailbox_ids ?keywords ?size ?received_at
438 ?message_id ?in_reply_to ?references ?sender ?from ?to_ ?cc ?bcc
439 ?reply_to ?subject ?sent_at ?has_attachment ?preview ?body_structure
440 ?body_values ?text_body ?html_body ?attachments ?headers
441 ~other_properties () in
442 match validate email with
443 | Ok () -> Ok email
444 | Error msg -> Error ("Email validation failed: " ^ msg)
445
446let take_id t =
447 match t.id with
448 | Some id -> id
449 | None -> failwith "Email object has no ID"
450
451let is_unread t =
452 match t.keywords with
453 | Some keywords ->
454 not (Keywords.is_draft keywords) &&
455 not (Keywords.is_seen keywords)
456 | None -> false (* Cannot determine without keywords *)
457
458let is_draft t =
459 match t.keywords with
460 | Some keywords -> Keywords.is_draft keywords
461 | None -> false
462
463let is_flagged t =
464 match t.keywords with
465 | Some keywords -> Keywords.is_flagged keywords
466 | None -> false
467
468let primary_sender t =
469 match t.from with
470 | Some (addr :: _) -> Some addr
471 | Some [] | None ->
472 t.sender
473
474let all_recipients t =
475 let to_list = match t.to_ with Some l -> l | None -> [] in
476 let cc_list = match t.cc with Some l -> l | None -> [] in
477 let bcc_list = match t.bcc with Some l -> l | None -> [] in
478 to_list @ cc_list @ bcc_list
479
480let display_summary t =
481 let sender_str = match primary_sender t with
482 | Some addr ->
483 (match Address.name addr with
484 | Some name -> name
485 | None -> Address.email addr)
486 | None -> "Unknown sender"
487 in
488 let subject_str = match t.subject with
489 | Some subj when subj <> "" -> subj
490 | _ -> "(No subject)"
491 in
492 let date_str = match t.received_at with
493 | Some date -> Printf.sprintf "%.0f" (Jmap.Date.to_timestamp date)
494 | None -> match t.sent_at with
495 | Some date -> Printf.sprintf "%.0f" (Jmap.Date.to_timestamp date)
496 | None -> "Unknown Jmap.Date.t"
497 in
498 Printf.sprintf "%s: %s (%s)" sender_str subject_str date_str
499
500(* PRINTABLE interface implementation *)
501let pp ppf t =
502 let id_str = match t.id with Some id -> Jmap.Id.to_string id | None -> "no-id" in
503 let subject_str = match t.subject with Some s -> s | None -> "(no subject)" in
504 Format.fprintf ppf "Email{id=%s; subject=%s}" id_str subject_str
505
506let pp_hum = pp
507
508(* JSON helper functions *)
509
510(* Complete JSON serialization for Email objects *)
511let to_json t =
512 let fields = [] in
513 let add_opt_string fields name str_opt = match str_opt with
514 | Some s -> (name, `String s) :: fields
515 | None -> fields
516 in
517 let add_opt_int fields name int_opt = match int_opt with
518 | Some i -> (name, `Int i) :: fields
519 | None -> fields
520 in
521 let add_opt_bool fields name bool_opt = match bool_opt with
522 | Some b -> (name, `Bool b) :: fields
523 | None -> fields
524 in
525 let add_opt_date fields name float_opt = match float_opt with
526 | Some f ->
527 let tm = Unix.gmtime f in
528 let iso_string = Printf.sprintf "%04d-%02d-%02dT%02d:%02d:%02dZ"
529 (tm.tm_year + 1900) (tm.tm_mon + 1) tm.tm_mday
530 tm.tm_hour tm.tm_min tm.tm_sec in
531 (name, `String iso_string) :: fields
532 | None -> fields
533 in
534 let add_opt_string_list fields name list_opt = match list_opt with
535 | Some lst -> (name, `List (List.map (fun s -> `String s) lst)) :: fields
536 | None -> fields
537 in
538 let add_opt_address_list fields name addr_list_opt = match addr_list_opt with
539 | Some addrs -> (name, `List (List.map Address.to_json addrs)) :: fields
540 | None -> fields
541 in
542 let add_opt_body_part_list fields name body_list_opt = match body_list_opt with
543 | Some body_parts -> (name, `List (List.map Body.to_json body_parts)) :: fields
544 | None -> fields
545 in
546 let add_opt_string_map fields name map_opt = match map_opt with
547 | Some map ->
548 let assoc_list = Hashtbl.fold (fun k v acc -> (k, `String v) :: acc) map [] in
549 (name, `Assoc assoc_list) :: fields
550 | None -> fields
551 in
552 let add_opt_bool_map fields name map_opt = match map_opt with
553 | Some map ->
554 let assoc_list = Hashtbl.fold (fun k v acc -> (Jmap.Id.to_string k, `Bool v) :: acc) map [] in
555 (name, `Assoc assoc_list) :: fields
556 | None -> fields
557 in
558 let add_opt_body_values fields name body_values_opt = match body_values_opt with
559 | Some body_values ->
560 let assoc_list = Hashtbl.fold (fun k v acc -> (k, Body.Value.to_json v) :: acc) body_values [] in
561 (name, `Assoc assoc_list) :: fields
562 | None -> fields
563 in
564
565 (* Add all email fields *)
566 let fields = add_opt_string fields "id" (Option.map Jmap.Id.to_string t.id) in
567 let fields = add_opt_string fields "blobId" (Option.map Jmap.Id.to_string t.blob_id) in
568 let fields = add_opt_string fields "threadId" (Option.map Jmap.Id.to_string t.thread_id) in
569 let fields = add_opt_bool_map fields "mailboxIds" t.mailbox_ids in
570 let fields = match t.keywords with
571 | Some kw -> ("keywords", Keywords.to_json kw) :: fields
572 | None -> fields
573 in
574 let fields = add_opt_int fields "size" (Option.map Jmap.UInt.to_int t.size) in
575 let fields = add_opt_date fields "receivedAt" (Option.map Jmap.Date.to_timestamp t.received_at) in
576 let fields = add_opt_string_list fields "messageId" t.message_id in
577 let fields = add_opt_string_list fields "inReplyTo" t.in_reply_to in
578 let fields = add_opt_string_list fields "references" t.references in
579 let fields = match t.sender with
580 | Some addr -> ("sender", `List [Address.to_json addr]) :: fields
581 | None -> fields
582 in
583 let fields = add_opt_address_list fields "from" t.from in
584 let fields = add_opt_address_list fields "to" t.to_ in
585 let fields = add_opt_address_list fields "cc" t.cc in
586 let fields = add_opt_address_list fields "bcc" t.bcc in
587 let fields = add_opt_address_list fields "replyTo" t.reply_to in
588 let fields = add_opt_string fields "subject" t.subject in
589 let fields = add_opt_date fields "sentAt" (Option.map Jmap.Date.to_timestamp t.sent_at) in
590 let fields = add_opt_bool fields "hasAttachment" t.has_attachment in
591 let fields = add_opt_string fields "preview" t.preview in
592 let fields = match t.body_structure with
593 | Some body -> ("bodyStructure", Body.to_json body) :: fields
594 | None -> fields
595 in
596 let fields = add_opt_body_values fields "bodyValues" t.body_values in
597 let fields = add_opt_body_part_list fields "textBody" t.text_body in
598 let fields = add_opt_body_part_list fields "htmlBody" t.html_body in
599 let fields = add_opt_body_part_list fields "attachments" t.attachments in
600 let fields = add_opt_string_map fields "headers" t.headers in
601
602 (* Add any other properties *)
603 let fields = if Hashtbl.length t.other_properties > 0 then
604 let other_fields = Hashtbl.fold (fun k v acc -> (k, v) :: acc) t.other_properties [] in
605 other_fields @ fields
606 else fields
607 in
608 `Assoc fields
609
610
611(** Enhanced JSON parsing with comprehensive validation *)
612let of_json_with_validation = function
613 | `Assoc fields ->
614 (try
615 (* Parse all email fields using combinators *)
616 let id = match Json.string "id" fields with
617 | Some id_str -> (match Jmap.Id.of_string id_str with
618 | Ok jmap_id -> Some jmap_id
619 | Error _ -> None)
620 | None -> None in
621 let blob_id = match Json.string "blobId" fields with
622 | Some blob_id_str -> (match Jmap.Id.of_string blob_id_str with
623 | Ok jmap_id -> Some jmap_id
624 | Error _ -> None)
625 | None -> None in
626 let thread_id = match Json.string "threadId" fields with
627 | Some thread_id_str -> (match Jmap.Id.of_string thread_id_str with
628 | Ok jmap_id -> Some jmap_id
629 | Error _ -> None)
630 | None -> None in
631 let mailbox_ids = match Json.bool_map "mailboxIds" fields with
632 | Some string_map ->
633 let id_map = Hashtbl.create (Hashtbl.length string_map) in
634 Hashtbl.iter (fun str_key bool_val ->
635 match Jmap.Id.of_string str_key with
636 | Ok id_key -> Hashtbl.add id_map id_key bool_val
637 | Error _ -> () (* Skip invalid ids *)
638 ) string_map;
639 if Hashtbl.length id_map > 0 then Some id_map else None
640 | None -> None in
641
642 (* Validate mailbox_ids if present *)
643 (match mailbox_ids with
644 | Some mids ->
645 (match Validation.validate_mailbox_ids mids with
646 | Ok () -> ()
647 | Error msg -> failwith ("Mailbox validation error: " ^ msg))
648 | None -> ());
649
650 (* Parse keywords with validation *)
651 let keywords = match Json.field "keywords" fields with
652 | Some json ->
653 (match Keywords.of_json json with
654 | Ok kw -> Some kw
655 | Error _msg -> None (* Parse failed *))
656 | None -> None
657 in
658 let size = match Json.int "size" fields with
659 | Some int_val -> (match Jmap.UInt.of_int int_val with
660 | Ok uint_val -> Some uint_val
661 | Error _ -> None)
662 | None -> None in
663
664 (* Validate size if present *)
665 (match Validation.validate_size size with
666 | Ok () -> ()
667 | Error msg -> failwith ("Size validation error: " ^ msg));
668
669 let received_at = match Json.iso_date "receivedAt" fields with
670 | Some float_val -> Some (Jmap.Date.of_timestamp float_val)
671 | None -> None in
672 let message_id = Json.string_list "messageId" fields in
673 let in_reply_to = Json.string_list "inReplyTo" fields in
674 let references = Json.string_list "references" fields in
675
676 (* Enhanced validation for message ID fields *)
677 (match Validation.validate_message_id_list message_id with
678 | Ok () -> ()
679 | Error msg -> failwith ("Message-ID validation error in messageId: " ^ msg));
680 (match Validation.validate_message_id_list in_reply_to with
681 | Ok () -> ()
682 | Error msg -> failwith ("Message-ID validation error in inReplyTo: " ^ msg));
683 (match Validation.validate_message_id_list references with
684 | Ok () -> ()
685 | Error msg -> failwith ("Message-ID validation error in references: " ^ msg));
686
687 let sender = match Json.email_address_list "sender" fields with
688 | Some [addr] -> Some addr
689 | _ -> None
690 in
691 let from = Json.email_address_list "from" fields in
692 let to_ = Json.email_address_list "to" fields in
693 let cc = Json.email_address_list "cc" fields in
694 let bcc = Json.email_address_list "bcc" fields in
695 let reply_to = Json.email_address_list "replyTo" fields in
696 let subject = Json.string "subject" fields in
697 let sent_at = match Json.iso_date "sentAt" fields with
698 | Some float_val -> Some (Jmap.Date.of_timestamp float_val)
699 | None -> None in
700 let has_attachment = Json.bool "hasAttachment" fields in
701 let preview = Json.string "preview" fields in
702 (* Parse body structure using the Body module *)
703 let body_structure = match Json.field "bodyStructure" fields with
704 | Some json ->
705 (match Body.of_json json with
706 | Ok body -> Some body
707 | Error _msg -> None (* Ignore parse errors for now *))
708 | None -> None
709 in
710 (* Parse body values map using Body.Value module *)
711 let body_values = match Json.field "bodyValues" fields with
712 | Some (`Assoc body_value_fields) ->
713 let parsed_values = Hashtbl.create (List.length body_value_fields) in
714 let parse_success = List.for_all (fun (part_id, body_value_json) ->
715 match Body.Value.of_json body_value_json with
716 | Ok body_value ->
717 Hashtbl.add parsed_values part_id body_value;
718 true
719 | Error _msg -> false (* Ignore individual parse errors for now *)
720 ) body_value_fields in
721 if parse_success && Hashtbl.length parsed_values > 0 then Some parsed_values else None
722 | Some _non_object -> None (* Invalid bodyValues format *)
723 | None -> None
724 in
725 (* Parse textBody, htmlBody, and attachments arrays using Body module *)
726 let text_body = match Json.field "textBody" fields with
727 | Some (`List body_part_jsons) ->
728 let parsed_parts = List.filter_map (fun json ->
729 match Body.of_json json with
730 | Ok body_part -> Some body_part
731 | Error _msg -> None (* Skip invalid parts for now *)
732 ) body_part_jsons in
733 if parsed_parts <> [] then Some parsed_parts else None
734 | Some _non_list -> None (* Invalid textBody format *)
735 | None -> None
736 in
737 let html_body = match Json.field "htmlBody" fields with
738 | Some (`List body_part_jsons) ->
739 let parsed_parts = List.filter_map (fun json ->
740 match Body.of_json json with
741 | Ok body_part -> Some body_part
742 | Error _msg -> None (* Skip invalid parts for now *)
743 ) body_part_jsons in
744 if parsed_parts <> [] then Some parsed_parts else None
745 | Some _non_list -> None (* Invalid htmlBody format *)
746 | None -> None
747 in
748 let attachments = match Json.field "attachments" fields with
749 | Some (`List body_part_jsons) ->
750 let parsed_parts = List.filter_map (fun json ->
751 match Body.of_json json with
752 | Ok body_part -> Some body_part
753 | Error _msg -> None (* Skip invalid parts for now *)
754 ) body_part_jsons in
755 if parsed_parts <> [] then Some parsed_parts else None
756 | Some _non_list -> None (* Invalid attachments format *)
757 | None -> None
758 in
759 let headers = Json.string_map "headers" fields in
760
761 (* Collect any unrecognized fields into other_properties *)
762 let known_fields = [
763 "id"; "blobId"; "threadId"; "mailboxIds"; "keywords"; "size"; "receivedAt";
764 "messageId"; "inReplyTo"; "references"; "sender"; "from"; "to"; "cc"; "bcc";
765 "replyTo"; "subject"; "sentAt"; "hasAttachment"; "preview"; "bodyStructure";
766 "bodyValues"; "textBody"; "htmlBody"; "attachments"; "headers"
767 ] in
768 let other_properties = Hashtbl.create 16 in
769 List.iter (fun (field_name, field_value) ->
770 if not (List.mem field_name known_fields) then
771 Hashtbl.add other_properties field_name field_value
772 ) fields;
773
774 Ok (create_full
775 ?id ?blob_id ?thread_id ?mailbox_ids ?keywords ?size ?received_at
776 ?message_id ?in_reply_to ?references ?sender ?from ?to_ ?cc ?bcc
777 ?reply_to ?subject ?sent_at ?has_attachment ?preview ?body_structure
778 ?body_values ?text_body ?html_body ?attachments ?headers
779 ~other_properties ())
780 with
781 | exn -> Error (Printf.sprintf "Email JSON parsing error: %s" (Printexc.to_string exn)))
782 | _ ->
783 Error "Email JSON must be an object"
784
785(* Complete JSON parsing implementation for Email objects using combinators *)
786let of_json = function
787 | `Assoc fields ->
788 (try
789 (* Parse all email fields using combinators *)
790 let id = match Json.string "id" fields with
791 | Some id_str -> (match Jmap.Id.of_string id_str with
792 | Ok jmap_id -> Some jmap_id
793 | Error _ -> None)
794 | None -> None in
795 let blob_id = match Json.string "blobId" fields with
796 | Some blob_id_str -> (match Jmap.Id.of_string blob_id_str with
797 | Ok jmap_id -> Some jmap_id
798 | Error _ -> None)
799 | None -> None in
800 let thread_id = match Json.string "threadId" fields with
801 | Some thread_id_str -> (match Jmap.Id.of_string thread_id_str with
802 | Ok jmap_id -> Some jmap_id
803 | Error _ -> None)
804 | None -> None in
805 let mailbox_ids = match Json.bool_map "mailboxIds" fields with
806 | Some string_map ->
807 let id_map = Hashtbl.create (Hashtbl.length string_map) in
808 Hashtbl.iter (fun str_key bool_val ->
809 match Jmap.Id.of_string str_key with
810 | Ok id_key -> Hashtbl.add id_map id_key bool_val
811 | Error _ -> () (* Skip invalid ids *)
812 ) string_map;
813 Some id_map
814 | None -> None in
815 (* Parse keywords using the Keywords module *)
816 let keywords = match Json.field "keywords" fields with
817 | Some json ->
818 (match Keywords.of_json json with
819 | Ok kw -> Some kw
820 | Error _msg -> None (* Ignore parse errors for now *))
821 | None -> None
822 in
823 let size = match Json.int "size" fields with
824 | Some int_val -> (match Jmap.UInt.of_int int_val with
825 | Ok uint_val -> Some uint_val
826 | Error _ -> None)
827 | None -> None in
828 let received_at = match Json.iso_date "receivedAt" fields with
829 | Some float_val -> Some (Jmap.Date.of_timestamp float_val)
830 | None -> None in
831 let message_id = Json.string_list "messageId" fields in
832 let in_reply_to = Json.string_list "inReplyTo" fields in
833 let references = Json.string_list "references" fields in
834 let sender = match Json.email_address_list "sender" fields with
835 | Some [addr] -> Some addr
836 | _ -> None
837 in
838 let from = Json.email_address_list "from" fields in
839 let to_ = Json.email_address_list "to" fields in
840 let cc = Json.email_address_list "cc" fields in
841 let bcc = Json.email_address_list "bcc" fields in
842 let reply_to = Json.email_address_list "replyTo" fields in
843 let subject = Json.string "subject" fields in
844 let sent_at = match Json.iso_date "sentAt" fields with
845 | Some float_val -> Some (Jmap.Date.of_timestamp float_val)
846 | None -> None in
847 let has_attachment = Json.bool "hasAttachment" fields in
848 let preview = Json.string "preview" fields in
849 (* Parse body structure using the Body module *)
850 let body_structure = match Json.field "bodyStructure" fields with
851 | Some json ->
852 (match Body.of_json json with
853 | Ok body -> Some body
854 | Error _msg -> None (* Ignore parse errors for now *))
855 | None -> None
856 in
857 (* Parse body values map using Body.Value module *)
858 let body_values = match Json.field "bodyValues" fields with
859 | Some (`Assoc body_value_fields) ->
860 let parsed_values = Hashtbl.create (List.length body_value_fields) in
861 let parse_success = List.for_all (fun (part_id, body_value_json) ->
862 match Body.Value.of_json body_value_json with
863 | Ok body_value ->
864 Hashtbl.add parsed_values part_id body_value;
865 true
866 | Error _msg -> false (* Ignore individual parse errors for now *)
867 ) body_value_fields in
868 if parse_success && Hashtbl.length parsed_values > 0 then Some parsed_values else None
869 | Some _non_object -> None (* Invalid bodyValues format *)
870 | None -> None
871 in
872 (* Parse textBody, htmlBody, and attachments arrays using Body module *)
873 let text_body = match Json.field "textBody" fields with
874 | Some (`List body_part_jsons) ->
875 let parsed_parts = List.filter_map (fun json ->
876 match Body.of_json json with
877 | Ok body_part -> Some body_part
878 | Error _msg -> None (* Skip invalid parts for now *)
879 ) body_part_jsons in
880 if parsed_parts <> [] then Some parsed_parts else None
881 | Some _non_list -> None (* Invalid textBody format *)
882 | None -> None
883 in
884 let html_body = match Json.field "htmlBody" fields with
885 | Some (`List body_part_jsons) ->
886 let parsed_parts = List.filter_map (fun json ->
887 match Body.of_json json with
888 | Ok body_part -> Some body_part
889 | Error _msg -> None (* Skip invalid parts for now *)
890 ) body_part_jsons in
891 if parsed_parts <> [] then Some parsed_parts else None
892 | Some _non_list -> None (* Invalid htmlBody format *)
893 | None -> None
894 in
895 let attachments = match Json.field "attachments" fields with
896 | Some (`List body_part_jsons) ->
897 let parsed_parts = List.filter_map (fun json ->
898 match Body.of_json json with
899 | Ok body_part -> Some body_part
900 | Error _msg -> None (* Skip invalid parts for now *)
901 ) body_part_jsons in
902 if parsed_parts <> [] then Some parsed_parts else None
903 | Some _non_list -> None (* Invalid attachments format *)
904 | None -> None
905 in
906 let headers = Json.string_map "headers" fields in
907
908 (* Collect any unrecognized fields into other_properties *)
909 let known_fields = [
910 "id"; "blobId"; "threadId"; "mailboxIds"; "keywords"; "size"; "receivedAt";
911 "messageId"; "inReplyTo"; "references"; "sender"; "from"; "to"; "cc"; "bcc";
912 "replyTo"; "subject"; "sentAt"; "hasAttachment"; "preview"; "bodyStructure";
913 "bodyValues"; "textBody"; "htmlBody"; "attachments"; "headers"
914 ] in
915 let other_properties = Hashtbl.create 16 in
916 List.iter (fun (field_name, field_value) ->
917 if not (List.mem field_name known_fields) then
918 Hashtbl.add other_properties field_name field_value
919 ) fields;
920
921 Ok (create_full
922 ?id ?blob_id ?thread_id ?mailbox_ids ?keywords ?size ?received_at
923 ?message_id ?in_reply_to ?references ?sender ?from ?to_ ?cc ?bcc
924 ?reply_to ?subject ?sent_at ?has_attachment ?preview ?body_structure
925 ?body_values ?text_body ?html_body ?attachments ?headers
926 ~other_properties ())
927 with
928 | exn -> Error (Printf.sprintf "Email JSON parsing error: %s" (Printexc.to_string exn)))
929 | _ ->
930 Error "Email JSON must be an object"
931
932(* Pretty printing implementation for PRINTABLE signature *)
933let pp ppf t =
934 let id_str = match t.id with
935 | Some id -> Jmap.Id.to_string id
936 | None -> "<no-id>"
937 in
938 let subject_str = match t.subject with
939 | Some subj -> subj
940 | None -> "<no-subject>"
941 in
942 let sender_str = match primary_sender t with
943 | Some addr -> Address.email addr
944 | None -> "<unknown-sender>"
945 in
946 Format.fprintf ppf "Email{Jmap.Id.t=%s; from=%s; subject=%s}"
947 id_str sender_str subject_str
948
949(* Alias for pp following Fmt conventions *)
950let pp_hum ppf t = pp ppf t
951
952
953(** Enhanced patch operations with validation *)
954module Patch = struct
955 let create ?add_keywords ?remove_keywords ?add_mailboxes ?remove_mailboxes () =
956 let _add_keywords = add_keywords in (* Acknowledge unused parameter *)
957 let _remove_keywords = remove_keywords in (* Acknowledge unused parameter *)
958 let _add_mailboxes = add_mailboxes in (* Acknowledge unused parameter *)
959 let _remove_mailboxes = remove_mailboxes in (* Acknowledge unused parameter *)
960 let patches = [] in
961
962 (* Validate keywords if provided *)
963 (match add_keywords with
964 | Some keywords ->
965 let keyword_list = Keywords.items keywords in
966 List.iter (fun kw ->
967 let kw_str = Keywords.keyword_to_string kw in
968 if not (Validation.is_valid_keyword kw_str) then
969 failwith (Printf.sprintf "Invalid keyword format: %s" kw_str)
970 ) keyword_list
971 | None -> ());
972
973 (* Simplified implementation - would build proper JSON patches *)
974 (`List patches : Yojson.Safe.t)
975
976 let mark_read () =
977 let keywords = Keywords.add (Keywords.empty ()) Keywords.Seen in
978 create ~add_keywords:keywords ()
979
980 let mark_unread () =
981 let keywords = Keywords.add (Keywords.empty ()) Keywords.Seen in
982 create ~remove_keywords:keywords ()
983
984 let flag () =
985 let keywords = Keywords.add (Keywords.empty ()) Keywords.Flagged in
986 create ~add_keywords:keywords ()
987
988 let unflag () =
989 let keywords = Keywords.add (Keywords.empty ()) Keywords.Flagged in
990 create ~remove_keywords:keywords ()
991
992 let move_to_mailboxes _mailbox_ids =
993 `List [] (* Simplified implementation *)
994end
995
996(* Module aliases for external access *)
997module Email_address = Address
998module Email_keywords = Keywords
999module Email_header = Header
1000module Email_body = Body
1001module Apple_mail = Apple
1002module Thread = Thread
1003module Identity = Identity
1004module Query = Query
1005module Email_response = Response
1006module Email_set = Set
1007module Email_changes = Changes
1008
1009(* Legacy aliases for compatibility *)
1010module Types = struct
1011 module Keywords = Keywords
1012 module Email_address = Address
1013 module Email = struct
1014 type nonrec t = t (* Alias the main email type *)
1015 let id t = t.id
1016 let received_at = received_at
1017 let subject = subject
1018 let from = from
1019 let keywords = keywords
1020 end
1021end