My agentic slop goes here. Not intended for anyone else!
1(** Email body part and body value implementation.
2
3 This module implements email body part types and operations as specified in
4 RFC 8621 Section 4.1.4. It provides MIME structure handling, content access,
5 and validation for email body parts and decoded content values.
6
7 @see <https://www.rfc-editor.org/rfc/rfc8621.html#section-4.1.4> RFC 8621, Section 4.1.4
8*)
9
10type t = {
11 id : string option;
12 blob_id : Jmap.Id.t option;
13 size : Jmap.UInt.t;
14 headers : Header.t list;
15 name : string option;
16 mime_type : string;
17 charset : string option;
18 disposition : string option;
19 disposition_params : (string, string) Hashtbl.t option;
20 cid : string option;
21 language : string list option;
22 location : string option;
23 sub_parts : t list option;
24 boundary : string option;
25 content_transfer_encoding : string option;
26 other_headers : (string, Yojson.Safe.t) Hashtbl.t;
27}
28
29let id t = t.id
30let blob_id t = t.blob_id
31let size t = t.size
32let headers t = t.headers
33let name t = t.name
34let mime_type t = t.mime_type
35let charset t = t.charset
36let disposition t = t.disposition
37let disposition_params t = t.disposition_params
38let cid t = t.cid
39let language t = t.language
40let location t = t.location
41let sub_parts t = t.sub_parts
42let boundary t = t.boundary
43let content_transfer_encoding t = t.content_transfer_encoding
44let other_headers t = t.other_headers
45
46(** MIME parameter parsing utilities *)
47module MIME_params = struct
48 (** Parse MIME parameters from a header value like "text/html; charset=utf-8; boundary=foo" *)
49 let parse_parameters (value : string) : (string * string) list =
50 let parts = Str.split (Str.regexp ";") value in
51 match parts with
52 | [] -> []
53 | _main_type :: param_parts ->
54 List.filter_map (fun part ->
55 let trimmed = String.trim part in
56 if String.contains trimmed '=' then
57 let equals_pos = String.index trimmed '=' in
58 let name = String.trim (String.sub trimmed 0 equals_pos) in
59 let value_part = String.trim (String.sub trimmed (equals_pos + 1) (String.length trimmed - equals_pos - 1)) in
60 (* Remove quotes if present *)
61 let clean_value =
62 if String.length value_part >= 2 && value_part.[0] = '"' && value_part.[String.length value_part - 1] = '"' then
63 String.sub value_part 1 (String.length value_part - 2)
64 else value_part
65 in
66 Some (String.lowercase_ascii name, clean_value)
67 else None
68 ) param_parts
69
70 (** Get main MIME type from a Content-Type value *)
71 let get_main_type (content_type : string) : string =
72 let parts = Str.split (Str.regexp ";") content_type in
73 match parts with
74 | main :: _ -> String.trim (String.lowercase_ascii main)
75 | [] -> content_type
76
77 (** Find a specific parameter value *)
78 let find_param (params : (string * string) list) (name : string) : string option =
79 List.assoc_opt (String.lowercase_ascii name) params
80end
81
82(** Content-Transfer-Encoding handling utilities *)
83module Encoding = struct
84 (** Decode quoted-printable encoded content *)
85 let decode_quoted_printable (content : string) : (string, string) result =
86 try
87 let buffer = Buffer.create (String.length content) in
88 let len = String.length content in
89 let rec process i =
90 if i >= len then ()
91 else if content.[i] = '=' && i + 2 < len then
92 let hex_str = String.sub content (i + 1) 2 in
93 if hex_str = "\r\n" || hex_str = "\n" then
94 process (i + 3) (* Soft line break *)
95 else
96 try
97 let byte_val = int_of_string ("0x" ^ hex_str) in
98 Buffer.add_char buffer (char_of_int byte_val);
99 process (i + 3)
100 with _ ->
101 Buffer.add_char buffer content.[i];
102 process (i + 1)
103 else (
104 Buffer.add_char buffer content.[i];
105 process (i + 1)
106 )
107 in
108 process 0;
109 Ok (Buffer.contents buffer)
110 with exn ->
111 Error ("Quoted-printable decoding failed: " ^ Printexc.to_string exn)
112
113 (** Decode base64 encoded content *)
114 let decode_base64 (content : string) : (string, string) result =
115 try
116 (* Remove whitespace and newlines *)
117 let clean_content = Str.global_replace (Str.regexp "[\r\n\t ]+") "" content in
118 match Base64.decode clean_content with
119 | Ok decoded -> Ok decoded
120 | Error (`Msg msg) -> Error ("Base64 decoding failed: " ^ msg)
121 with exn ->
122 Error ("Base64 decoding failed: " ^ Printexc.to_string exn)
123
124 (** Decode content based on Content-Transfer-Encoding *)
125 let decode_content (encoding : string option) (content : string) : (string * bool) =
126 match encoding with
127 | Some enc when String.lowercase_ascii enc = "quoted-printable" ->
128 (match decode_quoted_printable content with
129 | Ok decoded -> (decoded, false)
130 | Error _ -> (content, true)) (* Keep original on error, mark encoding problem *)
131 | Some enc when String.lowercase_ascii enc = "base64" ->
132 (match decode_base64 content with
133 | Ok decoded -> (decoded, false)
134 | Error _ -> (content, true)) (* Keep original on error, mark encoding problem *)
135 | Some "7bit" | Some "8bit" | Some "binary" | None ->
136 (content, false) (* No decoding needed *)
137 | Some _unknown ->
138 (content, true) (* Unknown encoding, mark as problem *)
139end
140
141let validate_mime_type mime_type =
142 if mime_type = "" then
143 Error "MIME type cannot be empty"
144 else if not (String.contains mime_type '/') then
145 Error "MIME type must contain a '/' separator"
146 else
147 Ok ()
148
149let validate_body_part ~id ~blob_id ~sub_parts ~mime_type =
150 match validate_mime_type mime_type with
151 | Error msg -> Error msg
152 | Ok () ->
153 let is_multipart = String.length mime_type >= 9 &&
154 String.sub mime_type 0 9 = "multipart" in
155 match is_multipart, sub_parts, id, blob_id with
156 | true, Some _, None, None -> Ok () (* Multipart with sub-parts *)
157 | true, Some _, _, _ -> Error "Multipart body parts cannot have id or blob_id"
158 | true, None, _, _ -> Error "Multipart body parts must have sub_parts"
159 | false, None, _, _ -> Ok () (* Leaf part without sub-parts *)
160 | false, Some _, _, _ -> Error "Non-multipart body parts cannot have sub_parts"
161
162let create ?id ?blob_id ~size ~headers ?name ~mime_type ?charset
163 ?disposition ?disposition_params ?cid ?language ?location ?sub_parts
164 ?boundary ?content_transfer_encoding ?(other_headers = Hashtbl.create 0) () =
165 match validate_body_part ~id ~blob_id ~sub_parts ~mime_type with
166 | Ok () ->
167 Ok {
168 id; blob_id; size; headers; name; mime_type; charset;
169 disposition; disposition_params; cid; language; location; sub_parts; boundary;
170 content_transfer_encoding; other_headers
171 }
172 | Error msg -> Error msg
173
174let create_unsafe ?id ?blob_id ~size ~headers ?name ~mime_type ?charset
175 ?disposition ?disposition_params ?cid ?language ?location ?sub_parts
176 ?boundary ?content_transfer_encoding ?(other_headers = Hashtbl.create 0) () =
177 {
178 id; blob_id; size; headers; name; mime_type; charset;
179 disposition; disposition_params; cid; language; location; sub_parts; boundary;
180 content_transfer_encoding; other_headers
181 }
182
183let is_multipart t =
184 String.length t.mime_type >= 9 && String.sub t.mime_type 0 9 = "multipart"
185
186let is_leaf t = not (is_multipart t)
187
188let is_attachment t =
189 match t.disposition with
190 | Some disp -> String.lowercase_ascii (String.trim disp) = "attachment"
191 | None ->
192 (* Use MIME type heuristics as per RFC 8621 *)
193 let lower_type = String.lowercase_ascii t.mime_type in
194 let is_inline_type =
195 lower_type = "text/plain" || lower_type = "text/html" ||
196 (String.length lower_type >= 6 && String.sub lower_type 0 6 = "image/") ||
197 (String.length lower_type >= 6 && String.sub lower_type 0 6 = "audio/") ||
198 (String.length lower_type >= 6 && String.sub lower_type 0 6 = "video/")
199 in
200 not is_inline_type
201
202let is_inline t =
203 match t.disposition with
204 | Some disp -> String.lowercase_ascii (String.trim disp) = "inline"
205 | None -> not (is_attachment t)
206
207let rec get_leaf_parts t =
208 match t.sub_parts with
209 | None -> [t] (* This is a leaf part *)
210 | Some parts -> List.concat_map get_leaf_parts parts
211
212let match_mime_type pattern mime_type =
213 if pattern = mime_type then true
214 else if String.contains pattern '*' then
215 let pattern_prefix = String.sub pattern 0 (String.index pattern '*') in
216 String.length mime_type >= String.length pattern_prefix &&
217 String.sub mime_type 0 (String.length pattern_prefix) = pattern_prefix
218 else false
219
220let rec find_by_mime_type t pattern =
221 let current_matches = if match_mime_type pattern t.mime_type then [t] else [] in
222 let sub_matches = match t.sub_parts with
223 | None -> []
224 | Some parts -> List.concat_map (fun p -> find_by_mime_type p pattern) parts
225 in
226 current_matches @ sub_matches
227
228(** Generate a unique part ID for a body part at given depth and position *)
229let generate_part_id (depth : int) (position : int) : string =
230 if depth = 0 then string_of_int position
231 else Printf.sprintf "%d.%d" depth position
232
233(** Validate part ID format *)
234let is_valid_part_id (part_id : string) : bool =
235 let id_re = Str.regexp "^[0-9]+\\(\\.[0-9]+\\)*$" in
236 Str.string_match id_re part_id 0
237
238(** Extract MIME parameters from Content-Type header *)
239let extract_mime_params (headers : Header.t list) : string option * (string * string) list =
240 match Header.find_by_name headers "content-type" with
241 | Some header ->
242 let content_type_value = Header.value header in
243 let params = MIME_params.parse_parameters content_type_value in
244 (Some content_type_value, params)
245 | None -> (None, [])
246
247(** Extract Content-Disposition parameters *)
248let extract_disposition_params (headers : Header.t list) : string option * (string * string) list =
249 match Header.find_by_name headers "content-disposition" with
250 | Some header ->
251 let disposition_value = Header.value header in
252 let params = MIME_params.parse_parameters disposition_value in
253 (Some (MIME_params.get_main_type disposition_value), params)
254 | None -> (None, [])
255
256(** Body structure flattening for textBody/htmlBody/attachments as per RFC 8621 algorithm *)
257module Flattener = struct
258 type flattened_parts = {
259 text_body : t list;
260 html_body : t list;
261 attachments : t list;
262 }
263
264 let empty_parts = { text_body = []; html_body = []; attachments = [] }
265
266 let is_inline_media_type mime_type =
267 let lower = String.lowercase_ascii mime_type in
268 String.length lower >= 6 && (
269 String.sub lower 0 6 = "image/" ||
270 String.sub lower 0 6 = "audio/" ||
271 String.sub lower 0 6 = "video/"
272 )
273
274 let rec flatten_structure (parts : t list) (multipart_type : string)
275 (in_alternative : bool) (acc : flattened_parts) : flattened_parts =
276 List.fold_left (fun acc part ->
277 let is_inline_part = is_inline part in
278 if is_multipart part then
279 match part.sub_parts with
280 | Some sub_parts ->
281 let sub_multipart_type =
282 let mime_parts = String.split_on_char '/' part.mime_type in
283 match mime_parts with
284 | ["multipart"; subtype] -> subtype
285 | _ -> "mixed"
286 in
287 flatten_structure sub_parts sub_multipart_type
288 (in_alternative || sub_multipart_type = "alternative") acc
289 | None -> acc
290 else if is_inline_part then
291 if multipart_type = "alternative" then
292 match String.lowercase_ascii part.mime_type with
293 | "text/plain" ->
294 { acc with text_body = part :: acc.text_body }
295 | "text/html" ->
296 { acc with html_body = part :: acc.html_body }
297 | _ ->
298 { acc with attachments = part :: acc.attachments }
299 else if in_alternative then
300 let new_acc = { acc with text_body = part :: acc.text_body;
301 html_body = part :: acc.html_body } in
302 if is_inline_media_type part.mime_type then
303 { new_acc with attachments = part :: new_acc.attachments }
304 else new_acc
305 else
306 let new_acc = { acc with text_body = part :: acc.text_body;
307 html_body = part :: acc.html_body } in
308 if is_inline_media_type part.mime_type then
309 { new_acc with attachments = part :: new_acc.attachments }
310 else new_acc
311 else
312 { acc with attachments = part :: acc.attachments }
313 ) acc parts
314
315 (** Flatten body structure into textBody, htmlBody, and attachments lists *)
316 let flatten (body_structure : t) : flattened_parts =
317 let result = flatten_structure [body_structure] "mixed" false empty_parts in
318 { text_body = List.rev result.text_body;
319 html_body = List.rev result.html_body;
320 attachments = List.rev result.attachments }
321end
322
323(** Get text body parts (for textBody property) *)
324let get_text_body (t : t) : t list =
325 let flattened = Flattener.flatten t in
326 flattened.text_body
327
328(** Get HTML body parts (for htmlBody property) *)
329let get_html_body (t : t) : t list =
330 let flattened = Flattener.flatten t in
331 flattened.html_body
332
333(** Get attachment parts (for attachments property) *)
334let get_attachments (t : t) : t list =
335 let flattened = Flattener.flatten t in
336 flattened.attachments
337
338
339let rec to_json t =
340 let fields = [
341 ("size", `Int (Jmap.UInt.to_int t.size));
342 ("headers", Header.list_to_json t.headers);
343 ("type", `String t.mime_type);
344 ] in
345 let add_opt_string fields name = function
346 | Some s -> (name, `String s) :: fields
347 | None -> fields
348 in
349 let add_opt_string_list fields name = function
350 | Some lst -> (name, `List (List.map (fun s -> `String s) lst)) :: fields
351 | None -> fields
352 in
353 let add_opt_hashtbl fields name = function
354 | Some tbl when Hashtbl.length tbl > 0 ->
355 let params = Hashtbl.fold (fun k v acc -> (k, `String v) :: acc) tbl [] in
356 (name, `Assoc params) :: fields
357 | _ -> fields
358 in
359 let fields = add_opt_string fields "partId" t.id in
360 let fields = add_opt_string fields "blobId" (Option.map Jmap.Id.to_string t.blob_id) in
361 let fields = add_opt_string fields "name" t.name in
362 let fields = add_opt_string fields "charset" t.charset in
363 let fields = add_opt_string fields "disposition" t.disposition in
364 let fields = add_opt_hashtbl fields "dispositionParams" t.disposition_params in
365 let fields = add_opt_string fields "cid" t.cid in
366 let fields = add_opt_string_list fields "language" t.language in
367 let fields = add_opt_string fields "location" t.location in
368 let fields = add_opt_string fields "boundary" t.boundary in
369 let fields = add_opt_string fields "contentTransferEncoding" t.content_transfer_encoding in
370 let fields = match t.sub_parts with
371 | Some parts -> ("subParts", `List (List.map to_json parts)) :: fields
372 | None -> fields
373 in
374 let fields = if Hashtbl.length t.other_headers > 0 then
375 let other_fields = Hashtbl.fold (fun k v acc -> (k, v) :: acc) t.other_headers [] in
376 other_fields @ fields
377 else fields
378 in
379 `Assoc fields
380
381let rec of_json = function
382 | `Assoc fields ->
383 (try
384 let size = match List.assoc_opt "size" fields with
385 | Some (`Int s) -> (match Jmap.UInt.of_int s with
386 | Ok uint -> uint
387 | Error _ -> failwith ("Invalid size: " ^ string_of_int s))
388 | _ -> failwith "Missing or invalid size field"
389 in
390 let headers = match List.assoc_opt "headers" fields with
391 | Some json ->
392 (match Header.list_of_json json with
393 | Ok h -> h
394 | Error msg -> failwith ("Invalid headers: " ^ msg))
395 | None -> []
396 in
397 let mime_type = match List.assoc_opt "type" fields with
398 | Some (`String t) -> t
399 | _ -> failwith "Missing or invalid type field"
400 in
401 let id = match List.assoc_opt "partId" fields with
402 | Some (`String s) -> Some s
403 | Some `Null | None -> None
404 | _ -> failwith "Invalid partId field"
405 in
406 let blob_id = match List.assoc_opt "blobId" fields with
407 | Some (`String s) -> (match Jmap.Id.of_string s with
408 | Ok id_t -> Some id_t
409 | Error _ -> failwith ("Invalid blob_id: " ^ s))
410 | Some `Null | None -> None
411 | _ -> failwith "Invalid blobId field"
412 in
413 let name = match List.assoc_opt "name" fields with
414 | Some (`String s) -> Some s
415 | Some `Null | None -> None
416 | _ -> failwith "Invalid name field"
417 in
418 let charset = match List.assoc_opt "charset" fields with
419 | Some (`String s) -> Some s
420 | Some `Null | None -> None
421 | _ -> failwith "Invalid charset field"
422 in
423 let disposition = match List.assoc_opt "disposition" fields with
424 | Some (`String s) -> Some s
425 | Some `Null | None -> None
426 | _ -> failwith "Invalid disposition field"
427 in
428 let cid = match List.assoc_opt "cid" fields with
429 | Some (`String s) -> Some s
430 | Some `Null | None -> None
431 | _ -> failwith "Invalid cid field"
432 in
433 let language = match List.assoc_opt "language" fields with
434 | Some (`List items) ->
435 Some (List.map (function
436 | `String s -> s
437 | _ -> failwith "Invalid language list item") items)
438 | Some `Null | None -> None
439 | _ -> failwith "Invalid language field"
440 in
441 let location = match List.assoc_opt "location" fields with
442 | Some (`String s) -> Some s
443 | Some `Null | None -> None
444 | _ -> failwith "Invalid location field"
445 in
446 let sub_parts = match List.assoc_opt "subParts" fields with
447 | Some (`List items) ->
448 Some (List.map (fun item ->
449 match of_json item with
450 | Ok part -> part
451 | Error msg -> failwith msg) items)
452 | Some `Null | None -> None
453 | _ -> failwith "Invalid subParts field"
454 in
455 let disposition_params = match List.assoc_opt "dispositionParams" fields with
456 | Some (`Assoc params) ->
457 let tbl = Hashtbl.create (List.length params) in
458 List.iter (function
459 | (k, `String v) -> Hashtbl.add tbl k v
460 | _ -> failwith "Invalid dispositionParams format"
461 ) params;
462 Some tbl
463 | Some `Null | None -> None
464 | _ -> failwith "Invalid dispositionParams field"
465 in
466 let boundary = match List.assoc_opt "boundary" fields with
467 | Some (`String s) -> Some s
468 | Some `Null | None -> None
469 | _ -> failwith "Invalid boundary field"
470 in
471 let content_transfer_encoding = match List.assoc_opt "contentTransferEncoding" fields with
472 | Some (`String s) -> Some s
473 | Some `Null | None -> None
474 | _ -> failwith "Invalid contentTransferEncoding field"
475 in
476 let other_headers = Hashtbl.create 0 in
477 (* Add any fields not in the standard set to other_headers *)
478 let standard_fields = [
479 "size"; "headers"; "type"; "partId"; "blobId"; "name";
480 "charset"; "disposition"; "dispositionParams"; "cid"; "language"; "location"; "subParts";
481 "boundary"; "contentTransferEncoding"
482 ] in
483 List.iter (fun (k, v) ->
484 if not (List.mem k standard_fields) then
485 Hashtbl.add other_headers k v
486 ) fields;
487 Ok {
488 id; blob_id; size; headers; name; mime_type; charset;
489 disposition; disposition_params; cid; language; location; sub_parts; boundary;
490 content_transfer_encoding; other_headers
491 }
492 with
493 | Failure msg -> Error msg
494 | exn -> Error (Printexc.to_string exn))
495 | _ ->
496 Error "Body part JSON must be an object"
497
498module Value = struct
499 type t = {
500 value : string;
501 has_encoding_problem : bool;
502 is_truncated : bool;
503 }
504
505 let value t = t.value
506 let has_encoding_problem t = t.has_encoding_problem
507 let is_truncated t = t.is_truncated
508
509 let create ~value ?(encoding_problem = false) ?(truncated = false) () =
510 {
511 value;
512 has_encoding_problem = encoding_problem;
513 is_truncated = truncated
514 }
515
516 (** Create from raw MIME part content with full decoding *)
517 let from_mime_part ~part_content ~content_type ~content_transfer_encoding ~max_bytes () =
518 let params = MIME_params.parse_parameters (Option.value content_type ~default:"text/plain") in
519 let charset = MIME_params.find_param params "charset" in
520 let (decoded_content, encoding_problem) =
521 Encoding.decode_content content_transfer_encoding part_content in
522
523 (* Apply size limit if specified *)
524 let (final_content, is_truncated) =
525 if max_bytes > 0 && String.length decoded_content > max_bytes then
526 (String.sub decoded_content 0 max_bytes, true)
527 else
528 (decoded_content, false)
529 in
530
531 (* TODO: Character set conversion would go here if implementing full charset support *)
532 let _ = charset in (* Acknowledge parameter to avoid warning *)
533
534 {
535 value = final_content;
536 has_encoding_problem = encoding_problem;
537 is_truncated
538 }
539
540 (** Check if body value contains text content suitable for display *)
541 let is_text_content (t : t) : bool =
542 not (String.trim t.value = "")
543
544 (** Get content length in bytes *)
545 let content_length (t : t) : int =
546 String.length t.value
547
548 (** Get content preview (first N characters) *)
549 let preview (t : t) ~max_chars : string =
550 if String.length t.value <= max_chars then
551 t.value
552 else
553 String.sub t.value 0 max_chars ^ "..."
554
555 let to_json t =
556 let fields = [("value", `String t.value)] in
557 let fields = if t.has_encoding_problem then
558 ("isEncodingProblem", `Bool true) :: fields
559 else fields in
560 let fields = if t.is_truncated then
561 ("isTruncated", `Bool true) :: fields
562 else fields in
563 `Assoc fields
564
565 let of_json = function
566 | `Assoc fields ->
567 (try
568 let value = match List.assoc_opt "value" fields with
569 | Some (`String v) -> v
570 | _ -> failwith "Missing or invalid value field"
571 in
572 let has_encoding_problem = match List.assoc_opt "isEncodingProblem" fields with
573 | Some (`Bool b) -> b
574 | Some `Null | None -> false
575 | _ -> failwith "Invalid isEncodingProblem field"
576 in
577 let is_truncated = match List.assoc_opt "isTruncated" fields with
578 | Some (`Bool b) -> b
579 | Some `Null | None -> false
580 | _ -> failwith "Invalid isTruncated field"
581 in
582 Ok { value; has_encoding_problem; is_truncated }
583 with
584 | Failure msg -> Error msg
585 | exn -> Error (Printexc.to_string exn))
586 | _ ->
587 Error "Body value JSON must be an object"
588end
589
590let pp fmt t =
591 Format.fprintf fmt "BodyPart{id=%s;mime_type=%s;size=%d;multipart=%b}"
592 (match t.id with Some s -> s | None -> "none")
593 t.mime_type
594 (Jmap.UInt.to_int t.size)
595 (is_multipart t)
596
597let pp_hum fmt t = pp fmt t