···11+{0 jmap}
22+33+{!modules: Jmap Jmap_top}
44+55+{1 Tutorial}
66+77+See the {!page-tutorial} for a comprehensive guide to using JMAP with OCaml,
88+including how types map to JSON and practical examples.
99+1010+{1 Browser Support}
1111+1212+For browser-based applications, see the [jmap-brr] package which provides
1313+a JMAP client using the Brr library and js_of_ocaml.
+494
doc/tutorial.mld
···11+{0 JMAP Tutorial}
22+33+This tutorial introduces JMAP (JSON Meta Application Protocol) and
44+demonstrates the [jmap] OCaml library through interactive examples. JMAP
55+is defined in {{:https://www.rfc-editor.org/rfc/rfc8620}RFC 8620} (core)
66+and {{:https://www.rfc-editor.org/rfc/rfc8621}RFC 8621} (mail).
77+88+{1 What is JMAP?}
99+1010+JMAP is a modern, efficient protocol for synchronizing mail and other
1111+data. It's designed as a better alternative to IMAP, addressing many of
1212+IMAP's limitations:
1313+1414+{ul
1515+{- {b Stateless over HTTP}: Unlike IMAP's persistent TCP connections, JMAP
1616+ uses standard HTTP POST requests with JSON payloads.}
1717+{- {b Efficient batching}: Multiple operations can be combined into a single
1818+ request, reducing round-trips.}
1919+{- {b Result references}: The output of one method call can be used as input
2020+ to another in the same request.}
2121+{- {b Push support}: Built-in mechanisms for real-time notifications.}
2222+{- {b Binary data handling}: Separate upload/download endpoints for large
2323+ attachments.}}
2424+2525+The core protocol (RFC 8620) defines the general structure, while RFC 8621
2626+extends it specifically for email, mailboxes, threads, and related objects.
2727+2828+{1 Setup}
2929+3030+First, let's set up our environment. In the toplevel, load the library
3131+with [#require "jmap.top";;] which will automatically install pretty
3232+printers.
3333+3434+{@ocaml[
3535+# Jmap_top.install ();;
3636+- : unit = ()
3737+# open Jmap;;
3838+]}
3939+4040+For parsing and encoding JSON, we'll use some helper functions:
4141+4242+{@ocaml[
4343+# let parse_json s =
4444+ match Jsont_bytesrw.decode_string Jsont.json s with
4545+ | Ok json -> json
4646+ | Error e -> failwith e;;
4747+val parse_json : string -> Jsont.json = <fun>
4848+# let json_to_string json =
4949+ match Jsont_bytesrw.encode_string ~format:Jsont.Indent Jsont.json json with
5050+ | Ok s -> s
5151+ | Error e -> failwith e;;
5252+val json_to_string : Jsont.json -> string = <fun>
5353+]}
5454+5555+{1 JMAP Identifiers}
5656+5757+From {{:https://www.rfc-editor.org/rfc/rfc8620#section-1.2}RFC 8620 Section 1.2}:
5858+5959+{i An "Id" is a String of at least 1 and a maximum of 255 octets in size,
6060+and it MUST only contain characters from the "URL and Filename Safe"
6161+base64 alphabet.}
6262+6363+The {!Jmap.Id} module provides type-safe identifiers:
6464+6565+{@ocaml[
6666+# let id = Id.of_string_exn "abc123";;
6767+val id : Id.t = abc123
6868+# Id.to_string id;;
6969+- : string = "abc123"
7070+]}
7171+7272+Invalid identifiers are rejected:
7373+7474+{@ocaml[
7575+# Id.of_string "";;
7676+- : (Id.t, string) result = Error "Id cannot be empty"
7777+# Id.of_string (String.make 256 'x');;
7878+- : (Id.t, string) result = Error "Id cannot exceed 255 characters"
7979+]}
8080+8181+{1 Keywords}
8282+8383+Email keywords are string flags that indicate message state. RFC 8621
8484+defines standard keywords, and the library represents them as polymorphic
8585+variants for type safety.
8686+8787+{2 Standard Keywords}
8888+8989+From {{:https://www.rfc-editor.org/rfc/rfc8621#section-4.1.1}RFC 8621
9090+Section 4.1.1}:
9191+9292+{@ocaml[
9393+# Keyword.of_string "$seen";;
9494+- : Keyword.t = $seen
9595+# Keyword.of_string "$flagged";;
9696+- : Keyword.t = $flagged
9797+# Keyword.of_string "$draft";;
9898+- : Keyword.t = $draft
9999+# Keyword.of_string "$answered";;
100100+- : Keyword.t = $answered
101101+]}
102102+103103+The standard keywords are:
104104+105105+{ul
106106+{- [`Seen] - The email has been read}
107107+{- [`Flagged] - The email has been flagged for attention}
108108+{- [`Draft] - The email is a draft being composed}
109109+{- [`Answered] - The email has been replied to}
110110+{- [`Forwarded] - The email has been forwarded}
111111+{- [`Phishing] - The email is likely phishing}
112112+{- [`Junk] - The email is spam}
113113+{- [`NotJunk] - The email is definitely not spam}}
114114+115115+{2 Extended Keywords}
116116+117117+The library also supports draft-ietf-mailmaint extended keywords:
118118+119119+{@ocaml[
120120+# Keyword.of_string "$notify";;
121121+- : Keyword.t = $notify
122122+# Keyword.of_string "$muted";;
123123+- : Keyword.t = $muted
124124+# Keyword.of_string "$hasattachment";;
125125+- : Keyword.t = $hasattachment
126126+]}
127127+128128+{2 Custom Keywords}
129129+130130+Unknown keywords are preserved as [`Custom]:
131131+132132+{@ocaml[
133133+# Keyword.of_string "$my_custom_flag";;
134134+- : Keyword.t = $my_custom_flag
135135+]}
136136+137137+{2 Converting Back to Strings}
138138+139139+{@ocaml[
140140+# Keyword.to_string `Seen;;
141141+- : string = "$seen"
142142+# Keyword.to_string `Flagged;;
143143+- : string = "$flagged"
144144+# Keyword.to_string (`Custom "$important");;
145145+- : string = "$important"
146146+]}
147147+148148+{1 Mailbox Roles}
149149+150150+Mailboxes can have special roles that indicate their purpose. From
151151+{{:https://www.rfc-editor.org/rfc/rfc8621#section-2}RFC 8621 Section 2}:
152152+153153+{@ocaml[
154154+# Role.of_string "inbox";;
155155+- : Role.t = inbox
156156+# Role.of_string "sent";;
157157+- : Role.t = sent
158158+# Role.of_string "drafts";;
159159+- : Role.t = drafts
160160+# Role.of_string "trash";;
161161+- : Role.t = trash
162162+# Role.of_string "junk";;
163163+- : Role.t = junk
164164+# Role.of_string "archive";;
165165+- : Role.t = archive
166166+]}
167167+168168+Custom roles are also supported:
169169+170170+{@ocaml[
171171+# Role.of_string "receipts";;
172172+- : Role.t = receipts
173173+]}
174174+175175+{1 Capabilities}
176176+177177+JMAP uses capability URIs to indicate supported features. From
178178+{{:https://www.rfc-editor.org/rfc/rfc8620#section-2}RFC 8620 Section 2}:
179179+180180+{@ocaml[
181181+# Capability.core_uri;;
182182+- : string = "urn:ietf:params:jmap:core"
183183+# Capability.mail_uri;;
184184+- : string = "urn:ietf:params:jmap:mail"
185185+# Capability.submission_uri;;
186186+- : string = "urn:ietf:params:jmap:submission"
187187+]}
188188+189189+{@ocaml[
190190+# Capability.of_string Capability.core_uri;;
191191+- : Capability.t = urn:ietf:params:jmap:core
192192+# Capability.of_string Capability.mail_uri;;
193193+- : Capability.t = urn:ietf:params:jmap:mail
194194+# Capability.of_string "urn:example:custom";;
195195+- : Capability.t = urn:example:custom
196196+]}
197197+198198+{1 Understanding JMAP JSON Structure}
199199+200200+One of the key benefits of JMAP over IMAP is its use of JSON. Let's see
201201+how OCaml types map to the wire format.
202202+203203+{2 Requests}
204204+205205+A JMAP request contains:
206206+- [using]: List of capability URIs required
207207+- [methodCalls]: Array of method invocations
208208+209209+Each method invocation is a triple: [methodName], [arguments], [callId].
210210+211211+Here's how a simple request is structured:
212212+213213+{x@ocaml[
214214+# let req = Jmap.Proto.Request.create
215215+ ~using:[Capability.core_uri; Capability.mail_uri]
216216+ ~method_calls:[
217217+ Jmap.Proto.Invocation.create
218218+ ~name:"Mailbox/get"
219219+ ~arguments:(parse_json {|{"accountId": "abc123"}|})
220220+ ~call_id:"c0"
221221+ ]
222222+ ();;
223223+Line 7, characters 18-22:
224224+Error: The function applied to this argument has type
225225+ method_call_id:string -> Proto.Invocation.t
226226+This argument cannot be applied with label ~call_id
227227+# Jmap_top.encode Jmap.Proto.Request.jsont req |> json_to_string |> print_endline;;
228228+Line 1, characters 42-45:
229229+Error: Unbound value req
230230+Hint: Did you mean ref?
231231+]x}
232232+233233+{2 Email Filter Conditions}
234234+235235+Filters demonstrate how complex query conditions map to JSON. From
236236+{{:https://www.rfc-editor.org/rfc/rfc8621#section-4.4.1}RFC 8621
237237+Section 4.4.1}:
238238+239239+{x@ocaml[
240240+# let filter_condition : Jmap.Proto.Email.Filter_condition.t = {
241241+ in_mailbox = Some (Id.of_string_exn "inbox123");
242242+ in_mailbox_other_than = None;
243243+ before = None;
244244+ after = None;
245245+ min_size = None;
246246+ max_size = None;
247247+ all_in_thread_have_keyword = None;
248248+ some_in_thread_have_keyword = None;
249249+ none_in_thread_have_keyword = None;
250250+ has_keyword = Some "$flagged";
251251+ not_keyword = None;
252252+ has_attachment = Some true;
253253+ text = None;
254254+ from = Some "alice@";
255255+ to_ = None;
256256+ cc = None;
257257+ bcc = None;
258258+ subject = Some "urgent";
259259+ body = None;
260260+ header = None;
261261+ };;
262262+Line 2, characters 23-52:
263263+Error: This expression has type Id.t but an expression was expected of type
264264+ Proto.Id.t
265265+# Jmap_top.encode Jmap.Proto.Email.Filter_condition.jsont filter_condition
266266+ |> json_to_string |> print_endline;;
267267+Line 1, characters 57-73:
268268+Error: Unbound value filter_condition
269269+]x}
270270+271271+Notice how:
272272+- OCaml record fields use [snake_case], but JSON uses [camelCase]
273273+- [None] values are omitted from JSON (not sent as [null])
274274+- The filter only includes non-empty conditions
275275+276276+{2 Filter Operators}
277277+278278+Filters can be combined with AND, OR, and NOT operators:
279279+280280+{x@ocaml[
281281+# let combined_filter = Jmap.Proto.Filter.Operator {
282282+ operator = `And;
283283+ conditions = [
284284+ Condition filter_condition;
285285+ Condition { filter_condition with has_keyword = Some "$seen" }
286286+ ]
287287+ };;
288288+Line 4, characters 17-33:
289289+Error: Unbound value filter_condition
290290+]x}
291291+292292+{1 Method Chaining}
293293+294294+One of JMAP's most powerful features is result references - using the
295295+output of one method as input to another. The {!Jmap.Chain} module
296296+provides a monadic interface for building such requests.
297297+298298+From {{:https://www.rfc-editor.org/rfc/rfc8620#section-3.7}RFC 8620
299299+Section 3.7}:
300300+301301+{i A method argument may use the result of a previous method invocation
302302+in the same request.}
303303+304304+{2 Basic Example}
305305+306306+Query for emails, then fetch their details:
307307+308308+{[
309309+open Jmap.Chain
310310+311311+let request, handle = build ~capabilities:[core; mail] begin
312312+ let* query = email_query ~account_id
313313+ ~filter:(Condition { in_mailbox = Some inbox_id; (* ... *) })
314314+ ~limit:50L ()
315315+ in
316316+ let* emails = email_get ~account_id
317317+ ~ids:(from_query query) (* Reference query results! *)
318318+ ~properties:["subject"; "from"; "receivedAt"]
319319+ ()
320320+ in
321321+ return emails
322322+end
323323+][
324324+{err@mdx-error[
325325+Line 3, characters 46-50:
326326+Error: Unbound value core
327327+]err}]}
328328+329329+The key insight is [from_query query] - this creates a reference to the
330330+[ids] array from the query response. The server processes both calls in
331331+sequence, substituting the reference with actual IDs.
332332+333333+{2 Creation and Submission}
334334+335335+Create a draft and send it in one request:
336336+337337+{[
338338+let* set_h, draft_cid = email_set ~account_id
339339+ ~create:[("draft1", draft_email_json)]
340340+ ()
341341+in
342342+let* _ = email_submission_set ~account_id
343343+ ~create:[("sub1", submission_json
344344+ ~email_id:(created_id_of_string "draft1") (* Reference creation! *)
345345+ ~identity_id)]
346346+ ()
347347+in
348348+return set_h
349349+][
350350+{err@mdx-error[
351351+Line 1, characters 1-5:
352352+Error: Unbound value ( let* )
353353+]err}]}
354354+355355+{2 The RFC 8620 Example}
356356+357357+The RFC provides a complex example: fetch from/date/subject for all
358358+emails in the first 10 threads in the inbox:
359359+360360+{[
361361+let* q = email_query ~account_id
362362+ ~filter:(Condition { in_mailbox = Some inbox_id; (* ... *) })
363363+ ~sort:[comparator ~is_ascending:false "receivedAt"]
364364+ ~collapse_threads:true ~limit:10L ()
365365+in
366366+let* e1 = email_get ~account_id
367367+ ~ids:(from_query q)
368368+ ~properties:["threadId"]
369369+ ()
370370+in
371371+let* threads = thread_get ~account_id
372372+ ~ids:(from_get_field e1 "threadId") (* Get threadIds from emails *)
373373+ ()
374374+in
375375+let* e2 = email_get ~account_id
376376+ ~ids:(from_get_field threads "emailIds") (* Get all emailIds in threads *)
377377+ ~properties:["from"; "receivedAt"; "subject"]
378378+ ()
379379+in
380380+return e2
381381+][
382382+{err@mdx-error[
383383+Line 1, characters 1-5:
384384+Error: Unbound value ( let* )
385385+]err}]}
386386+387387+This entire flow executes in a {e single HTTP request}!
388388+389389+{1 Error Handling}
390390+391391+JMAP has a structured error system with three levels:
392392+393393+{2 Request-Level Errors}
394394+395395+These are returned with HTTP error status codes and RFC 7807 Problem
396396+Details. From {{:https://www.rfc-editor.org/rfc/rfc8620#section-3.6.1}RFC
397397+8620 Section 3.6.1}:
398398+399399+{@ocaml[
400400+# Error.to_string (`Request {
401401+ Error.type_ = "urn:ietf:params:jmap:error:unknownCapability";
402402+ status = Some 400;
403403+ title = Some "Unknown Capability";
404404+ detail = Some "The server does not support 'urn:example:unsupported'";
405405+ limit = None;
406406+ });;
407407+- : string =
408408+"Request error: urn:ietf:params:jmap:error:unknownCapability (status 400): The server does not support 'urn:example:unsupported'"
409409+]}
410410+411411+{2 Method-Level Errors}
412412+413413+Individual method calls can fail while others succeed:
414414+415415+{@ocaml[
416416+# Error.to_string (`Method {
417417+ Error.type_ = "invalidArguments";
418418+ description = Some "The 'filter' argument is malformed";
419419+ });;
420420+- : string =
421421+"Method error: invalidArguments: The 'filter' argument is malformed"
422422+]}
423423+424424+{2 SetError}
425425+426426+Object-level errors in /set responses:
427427+428428+{@ocaml[
429429+# Error.to_string (`Set ("draft1", {
430430+ Error.type_ = "invalidProperties";
431431+ description = Some "Unknown property: foobar";
432432+ properties = Some ["foobar"];
433433+ }));;
434434+- : string =
435435+"Set error for draft1: invalidProperties: Unknown property: foobar"
436436+]}
437437+438438+{1 Using with FastMail}
439439+440440+FastMail is a popular JMAP provider. Here's how to connect:
441441+442442+{[
443443+(* Get a token from https://app.fastmail.com/settings/tokens *)
444444+let token = "your-api-token"
445445+446446+(* The session URL for FastMail *)
447447+let session_url = "https://api.fastmail.com/jmap/session"
448448+449449+(* For browser applications using jmap-brr: *)
450450+let main () =
451451+ let open Fut.Syntax in
452452+ let* conn = Jmap_brr.get_session
453453+ ~url:(Jstr.v session_url)
454454+ ~token:(Jstr.v token)
455455+ in
456456+ match conn with
457457+ | Error e -> Brr.Console.(error [str "Error:"; e]); Fut.return ()
458458+ | Ok conn ->
459459+ let session = Jmap_brr.session conn in
460460+ Brr.Console.(log [str "Connected as:";
461461+ str (Jmap.Session.username session)]);
462462+ Fut.return ()
463463+][
464464+{err@mdx-error[
465465+Line 9, characters 14-17:
466466+Error: Unbound module Fut
467467+Hint: Did you mean Fun?
468468+]err}]}
469469+470470+{1 Summary}
471471+472472+JMAP (RFC 8620/8621) provides a modern, efficient protocol for email:
473473+474474+{ol
475475+{- {b Sessions}: Discover capabilities and account information via GET request}
476476+{- {b Batching}: Combine multiple method calls in one request}
477477+{- {b References}: Use results from one method as input to another}
478478+{- {b Type Safety}: The [jmap] library uses polymorphic variants for keywords and roles}
479479+{- {b JSON Mapping}: OCaml types map cleanly to JMAP JSON structure}
480480+{- {b Browser Support}: The [jmap-brr] package enables browser-based clients}}
481481+482482+The [jmap] library provides:
483483+{ul
484484+{- {!Jmap} - High-level interface with abstract types}
485485+{- {!Jmap.Proto} - Low-level protocol types matching the RFCs}
486486+{- {!Jmap.Chain} - Monadic interface for request chaining}
487487+{- [Jmap_brr] - Browser support via Brr/js_of_ocaml (separate package)}}
488488+489489+{2 Key RFC References}
490490+491491+{ul
492492+{- {{:https://www.rfc-editor.org/rfc/rfc8620}RFC 8620}: JMAP Core}
493493+{- {{:https://www.rfc-editor.org/rfc/rfc8621}RFC 8621}: JMAP for Mail}
494494+{- {{:https://www.rfc-editor.org/rfc/rfc7807}RFC 7807}: Problem Details for HTTP APIs}}
···216216217217 (** Get active keywords as polymorphic variants. *)
218218 let keywords e =
219219- let kw_map = Proto.Email.keywords e in
220220- List.filter_map (fun (k, v) ->
221221- if v then Some (Keyword.of_string k) else None
222222- ) kw_map
219219+ match Proto.Email.keywords e with
220220+ | None -> []
221221+ | Some kw_map ->
222222+ List.filter_map (fun (k, v) ->
223223+ if v then Some (Keyword.of_string k) else None
224224+ ) kw_map
223225224226 (** Check if email has a specific keyword. *)
225227 let has_keyword kw e =
226228 let kw_str = Keyword.to_string kw in
227227- let kw_map = Proto.Email.keywords e in
228228- List.exists (fun (k, v) -> k = kw_str && v) kw_map
229229+ match Proto.Email.keywords e with
230230+ | None -> false
231231+ | Some kw_map -> List.exists (fun (k, v) -> k = kw_str && v) kw_map
229232230233 let from e = Proto.Email.from e
231234 let to_ e = Proto.Email.to_ e
+46-39
lib/core/jmap.mli
···267267 val create : ?name:string -> string -> t
268268end
269269270270-(** Email mailbox. *)
270270+(** Email mailbox.
271271+ All accessors return option types since responses only include requested properties. *)
271272module Mailbox : sig
272273 type t
273274274274- val id : t -> Id.t
275275- val name : t -> string
275275+ val id : t -> Id.t option
276276+ val name : t -> string option
276277 val parent_id : t -> Id.t option
277277- val sort_order : t -> int64
278278- val total_emails : t -> int64
279279- val unread_emails : t -> int64
280280- val total_threads : t -> int64
281281- val unread_threads : t -> int64
282282- val is_subscribed : t -> bool
278278+ val sort_order : t -> int64 option
279279+ val total_emails : t -> int64 option
280280+ val unread_emails : t -> int64 option
281281+ val total_threads : t -> int64 option
282282+ val unread_threads : t -> int64 option
283283+ val is_subscribed : t -> bool option
283284 val role : t -> Role.t option
284285285286 (** Mailbox rights. *)
···297298 val may_submit : t -> bool
298299 end
299300300300- val my_rights : t -> Rights.t
301301+ val my_rights : t -> Rights.t option
301302end
302303303303-(** Email thread. *)
304304+(** Email thread.
305305+ All accessors return option types since responses only include requested properties. *)
304306module Thread : sig
305307 type t
306308307307- val id : t -> Id.t
308308- val email_ids : t -> Id.t list
309309+ val id : t -> Id.t option
310310+ val email_ids : t -> Id.t list option
309311end
310312311313(** Email message. *)
···331333 val value_is_encoding_problem : value -> bool
332334 end
333335336336+ (** All accessors return option types since responses only include requested properties. *)
334337 type t
335338336336- val id : t -> Id.t
337337- val blob_id : t -> Id.t
338338- val thread_id : t -> Id.t
339339- val mailbox_ids : t -> (Id.t * bool) list
340340- val size : t -> int64
341341- val received_at : t -> Ptime.t
339339+ val id : t -> Id.t option
340340+ val blob_id : t -> Id.t option
341341+ val thread_id : t -> Id.t option
342342+ val mailbox_ids : t -> (Id.t * bool) list option
343343+ val size : t -> int64 option
344344+ val received_at : t -> Ptime.t option
342345 val message_id : t -> string list option
343346 val in_reply_to : t -> string list option
344347 val references : t -> string list option
345348 val subject : t -> string option
346349 val sent_at : t -> Ptime.t option
347347- val has_attachment : t -> bool
348348- val preview : t -> string
350350+ val has_attachment : t -> bool option
351351+ val preview : t -> string option
349352350350- (** Get active keywords as polymorphic variants. *)
353353+ (** Get active keywords as polymorphic variants.
354354+ Returns empty list if keywords property was not requested. *)
351355 val keywords : t -> Keyword.t list
352356353353- (** Check if email has a specific keyword. *)
357357+ (** Check if email has a specific keyword.
358358+ Returns false if keywords property was not requested. *)
354359 val has_keyword : Keyword.t -> t -> bool
355360356361 val from : t -> Email_address.t list option
···366371 val body_values : t -> (string * Body.value) list option
367372end
368373369369-(** Email identity for sending. *)
374374+(** Email identity for sending.
375375+ All accessors return option types since responses only include requested properties. *)
370376module Identity : sig
371377 type t
372378373373- val id : t -> Id.t
374374- val name : t -> string
375375- val email : t -> string
379379+ val id : t -> Id.t option
380380+ val name : t -> string option
381381+ val email : t -> string option
376382 val reply_to : t -> Email_address.t list option
377383 val bcc : t -> Email_address.t list option
378378- val text_signature : t -> string
379379- val html_signature : t -> string
380380- val may_delete : t -> bool
384384+ val text_signature : t -> string option
385385+ val html_signature : t -> string option
386386+ val may_delete : t -> bool option
381387end
382388383383-(** Email submission for outgoing mail. *)
389389+(** Email submission for outgoing mail.
390390+ All accessors return option types since responses only include requested properties. *)
384391module Submission : sig
385392 type t
386393387387- val id : t -> Id.t
388388- val identity_id : t -> Id.t
389389- val email_id : t -> Id.t
390390- val thread_id : t -> Id.t
391391- val send_at : t -> Ptime.t
392392- val undo_status : t -> Proto.Submission.undo_status
394394+ val id : t -> Id.t option
395395+ val identity_id : t -> Id.t option
396396+ val email_id : t -> Id.t option
397397+ val thread_id : t -> Id.t option
398398+ val send_at : t -> Ptime.t option
399399+ val undo_status : t -> Proto.Submission.undo_status option
393400 val delivery_status : t -> (string * Proto.Submission.Delivery_status.t) list option
394394- val dsn_blob_ids : t -> Id.t list
395395- val mdn_blob_ids : t -> Id.t list
401401+ val dsn_blob_ids : t -> Id.t list option
402402+ val mdn_blob_ids : t -> Id.t list option
396403end
397404398405(** Vacation auto-response. *)
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+open Brr
77+open Fut.Syntax
88+99+type connection = {
1010+ session : Jmap.Proto.Session.t;
1111+ api_url : Jstr.t;
1212+ token : Jstr.t;
1313+}
1414+1515+let session conn = conn.session
1616+let api_url conn = conn.api_url
1717+1818+(* JSON logging callbacks *)
1919+let on_request : (string -> string -> unit) option ref = ref None
2020+let on_response : (string -> string -> unit) option ref = ref None
2121+2222+let set_request_logger f = on_request := Some f
2323+let set_response_logger f = on_response := Some f
2424+2525+let log_request label json =
2626+ match !on_request with
2727+ | Some f -> f label json
2828+ | None -> ()
2929+3030+let log_response label json =
3131+ match !on_response with
3232+ | Some f -> f label json
3333+ | None -> ()
3434+3535+(* JSON encoding/decoding using jsont.brr *)
3636+3737+let encode_request req =
3838+ Jsont_brr.encode Jmap.Proto.Request.jsont req
3939+4040+let encode_response resp =
4141+ Jsont_brr.encode Jmap.Proto.Response.jsont resp
4242+4343+let encode_session session =
4444+ Jsont_brr.encode Jmap.Proto.Session.jsont session
4545+4646+let decode_json s =
4747+ match Brr.Json.decode s with
4848+ | Ok jv -> Ok (Obj.magic jv : Jsont.json) (* Jv.t and Jsont.json are compatible *)
4949+ | Error e -> Error e
5050+5151+let encode_json json =
5252+ Ok (Brr.Json.encode (Obj.magic json : Jv.t))
5353+5454+let pp_json ppf json =
5555+ match encode_json json with
5656+ | Ok s -> Format.pp_print_string ppf (Jstr.to_string s)
5757+ | Error _ -> Format.pp_print_string ppf "<json encoding error>"
5858+5959+(* HTTP helpers *)
6060+6161+let make_headers token =
6262+ Brr_io.Fetch.Headers.of_assoc [
6363+ Jstr.v "Authorization", Jstr.(v "Bearer " + token);
6464+ Jstr.v "Content-Type", Jstr.v "application/json";
6565+ Jstr.v "Accept", Jstr.v "application/json";
6666+ ]
6767+6868+let fetch_json ~url ~meth ~headers ?body () =
6969+ Console.(log [str ">>> Request:"; str (Jstr.to_string meth); str (Jstr.to_string url)]);
7070+ (match body with
7171+ | Some b -> Console.(log [str ">>> Body:"; b])
7272+ | None -> Console.(log [str ">>> No body"]));
7373+ let init = Brr_io.Fetch.Request.init
7474+ ~method':meth
7575+ ~headers
7676+ ?body
7777+ ()
7878+ in
7979+ let req = Brr_io.Fetch.Request.v ~init url in
8080+ let* response = Brr_io.Fetch.request req in
8181+ match response with
8282+ | Error e ->
8383+ Console.(error [str "<<< Fetch error:"; e]);
8484+ Fut.return (Error e)
8585+ | Ok resp ->
8686+ let status = Brr_io.Fetch.Response.status resp in
8787+ Console.(log [str "<<< Response status:"; str (Jstr.of_int status)]);
8888+ if not (Brr_io.Fetch.Response.ok resp) then begin
8989+ let msg = Jstr.(v "HTTP error: " + of_int status) in
9090+ (* Try to get response body for error details *)
9191+ let body = Brr_io.Fetch.Response.as_body resp in
9292+ let* text = Brr_io.Fetch.Body.text body in
9393+ (match text with
9494+ | Ok t -> Console.(error [str "<<< Error body:"; str (Jstr.to_string t)])
9595+ | Error _ -> ());
9696+ Fut.return (Error (Jv.Error.v msg))
9797+ end else begin
9898+ let body = Brr_io.Fetch.Response.as_body resp in
9999+ let* text = Brr_io.Fetch.Body.text body in
100100+ match text with
101101+ | Error e ->
102102+ Console.(error [str "<<< Body read error:"; e]);
103103+ Fut.return (Error e)
104104+ | Ok text ->
105105+ Console.(log [str "<<< Response body:"; str (Jstr.to_string text)]);
106106+ Fut.return (Ok text)
107107+ end
108108+109109+(* Session establishment *)
110110+111111+let get_session ~url ~token =
112112+ Console.(log [str "get_session: token length ="; str (Jstr.of_int (Jstr.length token))]);
113113+ log_request "GET Session" (Printf.sprintf "{\"url\": \"%s\"}" (Jstr.to_string url));
114114+ let headers = make_headers token in
115115+ let* result = fetch_json ~url ~meth:(Jstr.v "GET") ~headers () in
116116+ match result with
117117+ | Error e -> Fut.return (Error e)
118118+ | Ok text ->
119119+ log_response "Session" (Jstr.to_string text);
120120+ match Jsont_brr.decode Jmap.Proto.Session.jsont text with
121121+ | Error e -> Fut.return (Error e)
122122+ | Ok session ->
123123+ let api_url = Jstr.v (Jmap.Proto.Session.api_url session) in
124124+ Fut.return (Ok { session; api_url; token })
125125+126126+(* Making requests *)
127127+128128+let request conn req =
129129+ let headers = make_headers conn.token in
130130+ match Jsont_brr.encode Jmap.Proto.Request.jsont req with
131131+ | Error e -> Fut.return (Error e)
132132+ | Ok body_str ->
133133+ log_request "JMAP Request" (Jstr.to_string body_str);
134134+ let body = Brr_io.Fetch.Body.of_jstr body_str in
135135+ let* result = fetch_json
136136+ ~url:conn.api_url
137137+ ~meth:(Jstr.v "POST")
138138+ ~headers
139139+ ~body
140140+ ()
141141+ in
142142+ match result with
143143+ | Error e -> Fut.return (Error e)
144144+ | Ok text ->
145145+ log_response "JMAP Response" (Jstr.to_string text);
146146+ match Jsont_brr.decode Jmap.Proto.Response.jsont text with
147147+ | Error e -> Fut.return (Error e)
148148+ | Ok response -> Fut.return (Ok response)
149149+150150+let request_json conn json =
151151+ let headers = make_headers conn.token in
152152+ match encode_json json with
153153+ | Error e -> Fut.return (Error e)
154154+ | Ok body_str ->
155155+ let body = Brr_io.Fetch.Body.of_jstr body_str in
156156+ let* result = fetch_json
157157+ ~url:conn.api_url
158158+ ~meth:(Jstr.v "POST")
159159+ ~headers
160160+ ~body
161161+ ()
162162+ in
163163+ match result with
164164+ | Error e -> Fut.return (Error e)
165165+ | Ok text ->
166166+ match decode_json text with
167167+ | Error e -> Fut.return (Error e)
168168+ | Ok json -> Fut.return (Ok json)
169169+170170+(* Toplevel support *)
171171+172172+let install_printers () =
173173+ (* In browser context, printers are registered via the OCaml console *)
174174+ Console.(log [str "JMAP printers installed"])
+107
lib/js/jmap_brr.mli
···11+(*---------------------------------------------------------------------------
22+ Copyright (c) 2025 Anil Madhavapeddy. All rights reserved.
33+ SPDX-License-Identifier: ISC
44+ ---------------------------------------------------------------------------*)
55+66+(** JMAP client for browsers using Brr.
77+88+ This module provides a JMAP client that runs in web browsers using
99+ the Fetch API. It can be used with js_of_ocaml to build browser-based
1010+ email clients.
1111+1212+ {2 Example}
1313+1414+ {[
1515+ open Fut.Syntax
1616+1717+ let main () =
1818+ let* session = Jmap_brr.get_session
1919+ ~url:(Jstr.v "https://api.fastmail.com/jmap/session")
2020+ ~token:(Jstr.v "your-api-token")
2121+ in
2222+ match session with
2323+ | Error e -> Brr.Console.(error [str "Session error:"; e]); Fut.return ()
2424+ | Ok session ->
2525+ Brr.Console.(log [str "Connected as:"; str (Jmap.Session.username session)]);
2626+ Fut.return ()
2727+2828+ let () = ignore (main ())
2929+ ]} *)
3030+3131+(** {1 Connection} *)
3232+3333+type connection
3434+(** A JMAP connection to a server. *)
3535+3636+val session : connection -> Jmap.Proto.Session.t
3737+(** [session conn] returns the session information. *)
3838+3939+val api_url : connection -> Jstr.t
4040+(** [api_url conn] returns the API URL for requests. *)
4141+4242+(** {1 Session Establishment} *)
4343+4444+val get_session :
4545+ url:Jstr.t ->
4646+ token:Jstr.t ->
4747+ (connection, Jv.Error.t) result Fut.t
4848+(** [get_session ~url ~token] establishes a JMAP session.
4949+5050+ [url] is the session URL (e.g., ["https://api.fastmail.com/jmap/session"]).
5151+ [token] is the Bearer authentication token. *)
5252+5353+(** {1 Making Requests} *)
5454+5555+val request :
5656+ connection ->
5757+ Jmap.Proto.Request.t ->
5858+ (Jmap.Proto.Response.t, Jv.Error.t) result Fut.t
5959+(** [request conn req] sends a JMAP request and returns the response. *)
6060+6161+val request_json :
6262+ connection ->
6363+ Jsont.json ->
6464+ (Jsont.json, Jv.Error.t) result Fut.t
6565+(** [request_json conn json] sends a raw JSON request and returns the
6666+ JSON response. Useful for debugging or custom requests. *)
6767+6868+(** {1 JSON Encoding Utilities}
6969+7070+ These functions help visualize how OCaml types map to JMAP JSON,
7171+ useful for the tutorial and debugging. *)
7272+7373+val encode_request : Jmap.Proto.Request.t -> (Jstr.t, Jv.Error.t) result
7474+(** [encode_request req] encodes a request to JSON string. *)
7575+7676+val encode_response : Jmap.Proto.Response.t -> (Jstr.t, Jv.Error.t) result
7777+(** [encode_response resp] encodes a response to JSON string. *)
7878+7979+val encode_session : Jmap.Proto.Session.t -> (Jstr.t, Jv.Error.t) result
8080+(** [encode_session session] encodes a session to JSON string. *)
8181+8282+val decode_json : Jstr.t -> (Jsont.json, Jv.Error.t) result
8383+(** [decode_json s] parses a JSON string to a Jsont.json value. *)
8484+8585+val encode_json : Jsont.json -> (Jstr.t, Jv.Error.t) result
8686+(** [encode_json json] encodes a Jsont.json value to a string. *)
8787+8888+val pp_json : Format.formatter -> Jsont.json -> unit
8989+(** [pp_json ppf json] pretty-prints JSON. For toplevel use. *)
9090+9191+(** {1 Protocol Logging} *)
9292+9393+val set_request_logger : (string -> string -> unit) -> unit
9494+(** [set_request_logger f] registers a callback [f label json] that will be
9595+ called with each outgoing JMAP request. Useful for debugging and
9696+ educational displays. *)
9797+9898+val set_response_logger : (string -> string -> unit) -> unit
9999+(** [set_response_logger f] registers a callback [f label json] that will be
100100+ called with each incoming JMAP response. Useful for debugging and
101101+ educational displays. *)
102102+103103+(** {1 Toplevel Support} *)
104104+105105+val install_printers : unit -> unit
106106+(** [install_printers ()] installs toplevel pretty printers for JMAP types.
107107+ This is useful when using the OCaml console in the browser. *)
+311-66
lib/mail/mail_email.ml
···4545 | `Gray
4646 ]
47474848- (* Flag color bitmask:
4949- - 000 = red, 100 = orange, 010 = yellow, 111 = green
5050- - 001 = blue, 101 = purple, 011 = gray *)
5148 let flag_color_to_keywords = function
5252- | `Red -> [] (* 000 - no bits set *)
5353- | `Orange -> [mail_flag_bit0] (* 100 *)
5454- | `Yellow -> [mail_flag_bit1] (* 010 *)
5555- | `Green -> [mail_flag_bit0; mail_flag_bit1; mail_flag_bit2] (* 111 *)
5656- | `Blue -> [mail_flag_bit2] (* 001 *)
5757- | `Purple -> [mail_flag_bit0; mail_flag_bit2] (* 101 *)
5858- | `Gray -> [mail_flag_bit1; mail_flag_bit2] (* 011 *)
4949+ | `Red -> []
5050+ | `Orange -> [mail_flag_bit0]
5151+ | `Yellow -> [mail_flag_bit1]
5252+ | `Green -> [mail_flag_bit0; mail_flag_bit1; mail_flag_bit2]
5353+ | `Blue -> [mail_flag_bit2]
5454+ | `Purple -> [mail_flag_bit0; mail_flag_bit2]
5555+ | `Gray -> [mail_flag_bit1; mail_flag_bit2]
59566057 let flag_color_of_keywords keywords =
6158 let has k = List.mem k keywords in
···7067 | (false, false, true) -> Some `Blue
7168 | (true, false, true) -> Some `Purple
7269 | (false, true, true) -> Some `Gray
7373- | (true, true, false) -> None (* Invalid combination *)
7070+ | (true, true, false) -> None
7471end
75727373+(* Email property types *)
7474+7575+type metadata_property = [
7676+ | `Id
7777+ | `Blob_id
7878+ | `Thread_id
7979+ | `Mailbox_ids
8080+ | `Keywords
8181+ | `Size
8282+ | `Received_at
8383+]
8484+8585+type header_convenience_property = [
8686+ | `Message_id
8787+ | `In_reply_to
8888+ | `References
8989+ | `Sender
9090+ | `From
9191+ | `To
9292+ | `Cc
9393+ | `Bcc
9494+ | `Reply_to
9595+ | `Subject
9696+ | `Sent_at
9797+ | `Headers
9898+]
9999+100100+type body_property = [
101101+ | `Body_structure
102102+ | `Body_values
103103+ | `Text_body
104104+ | `Html_body
105105+ | `Attachments
106106+ | `Has_attachment
107107+ | `Preview
108108+]
109109+110110+type standard_property = [
111111+ | metadata_property
112112+ | header_convenience_property
113113+ | body_property
114114+]
115115+116116+type header_property = [ `Header of Mail_header.header_property ]
117117+118118+type property = [ standard_property | header_property ]
119119+120120+let standard_property_to_string : [< standard_property ] -> string = function
121121+ | `Id -> "id"
122122+ | `Blob_id -> "blobId"
123123+ | `Thread_id -> "threadId"
124124+ | `Mailbox_ids -> "mailboxIds"
125125+ | `Keywords -> "keywords"
126126+ | `Size -> "size"
127127+ | `Received_at -> "receivedAt"
128128+ | `Message_id -> "messageId"
129129+ | `In_reply_to -> "inReplyTo"
130130+ | `References -> "references"
131131+ | `Sender -> "sender"
132132+ | `From -> "from"
133133+ | `To -> "to"
134134+ | `Cc -> "cc"
135135+ | `Bcc -> "bcc"
136136+ | `Reply_to -> "replyTo"
137137+ | `Subject -> "subject"
138138+ | `Sent_at -> "sentAt"
139139+ | `Headers -> "headers"
140140+ | `Body_structure -> "bodyStructure"
141141+ | `Body_values -> "bodyValues"
142142+ | `Text_body -> "textBody"
143143+ | `Html_body -> "htmlBody"
144144+ | `Attachments -> "attachments"
145145+ | `Has_attachment -> "hasAttachment"
146146+ | `Preview -> "preview"
147147+148148+let property_to_string : [< property ] -> string = function
149149+ | `Header hp -> Mail_header.header_property_to_string hp
150150+ | #standard_property as p -> standard_property_to_string p
151151+152152+let standard_property_of_string s : standard_property option =
153153+ match s with
154154+ | "id" -> Some `Id
155155+ | "blobId" -> Some `Blob_id
156156+ | "threadId" -> Some `Thread_id
157157+ | "mailboxIds" -> Some `Mailbox_ids
158158+ | "keywords" -> Some `Keywords
159159+ | "size" -> Some `Size
160160+ | "receivedAt" -> Some `Received_at
161161+ | "messageId" -> Some `Message_id
162162+ | "inReplyTo" -> Some `In_reply_to
163163+ | "references" -> Some `References
164164+ | "sender" -> Some `Sender
165165+ | "from" -> Some `From
166166+ | "to" -> Some `To
167167+ | "cc" -> Some `Cc
168168+ | "bcc" -> Some `Bcc
169169+ | "replyTo" -> Some `Reply_to
170170+ | "subject" -> Some `Subject
171171+ | "sentAt" -> Some `Sent_at
172172+ | "headers" -> Some `Headers
173173+ | "bodyStructure" -> Some `Body_structure
174174+ | "bodyValues" -> Some `Body_values
175175+ | "textBody" -> Some `Text_body
176176+ | "htmlBody" -> Some `Html_body
177177+ | "attachments" -> Some `Attachments
178178+ | "hasAttachment" -> Some `Has_attachment
179179+ | "preview" -> Some `Preview
180180+ | _ -> None
181181+182182+let property_of_string s : property option =
183183+ match standard_property_of_string s with
184184+ | Some p -> Some (p :> property)
185185+ | None ->
186186+ match Mail_header.header_property_of_string s with
187187+ | Some hp -> Some (`Header hp)
188188+ | None -> None
189189+190190+(* Body part properties *)
191191+192192+type body_part_property = [
193193+ | `Part_id
194194+ | `Blob_id
195195+ | `Size
196196+ | `Part_headers
197197+ | `Name
198198+ | `Type
199199+ | `Charset
200200+ | `Disposition
201201+ | `Cid
202202+ | `Language
203203+ | `Location
204204+ | `Sub_parts
205205+]
206206+207207+let body_part_property_to_string : [< body_part_property ] -> string = function
208208+ | `Part_id -> "partId"
209209+ | `Blob_id -> "blobId"
210210+ | `Size -> "size"
211211+ | `Part_headers -> "headers"
212212+ | `Name -> "name"
213213+ | `Type -> "type"
214214+ | `Charset -> "charset"
215215+ | `Disposition -> "disposition"
216216+ | `Cid -> "cid"
217217+ | `Language -> "language"
218218+ | `Location -> "location"
219219+ | `Sub_parts -> "subParts"
220220+221221+let body_part_property_of_string s : body_part_property option =
222222+ match s with
223223+ | "partId" -> Some `Part_id
224224+ | "blobId" -> Some `Blob_id
225225+ | "size" -> Some `Size
226226+ | "headers" -> Some `Part_headers
227227+ | "name" -> Some `Name
228228+ | "type" -> Some `Type
229229+ | "charset" -> Some `Charset
230230+ | "disposition" -> Some `Disposition
231231+ | "cid" -> Some `Cid
232232+ | "language" -> Some `Language
233233+ | "location" -> Some `Location
234234+ | "subParts" -> Some `Sub_parts
235235+ | _ -> None
236236+237237+(* Email type with optional fields *)
238238+76239type t = {
7777- id : Proto_id.t;
7878- blob_id : Proto_id.t;
7979- thread_id : Proto_id.t;
8080- size : int64;
8181- received_at : Ptime.t;
8282- mailbox_ids : (Proto_id.t * bool) list;
8383- keywords : (string * bool) list;
240240+ id : Proto_id.t option;
241241+ blob_id : Proto_id.t option;
242242+ thread_id : Proto_id.t option;
243243+ size : int64 option;
244244+ received_at : Ptime.t option;
245245+ mailbox_ids : (Proto_id.t * bool) list option;
246246+ keywords : (string * bool) list option;
84247 message_id : string list option;
85248 in_reply_to : string list option;
86249 references : string list option;
···98261 text_body : Mail_body.Part.t list option;
99262 html_body : Mail_body.Part.t list option;
100263 attachments : Mail_body.Part.t list option;
101101- has_attachment : bool;
102102- preview : string;
264264+ has_attachment : bool option;
265265+ preview : string option;
266266+ dynamic_headers : (string * Jsont.json) list;
103267}
104268105269let id t = t.id
···128292let attachments t = t.attachments
129293let has_attachment t = t.has_attachment
130294let preview t = t.preview
295295+let dynamic_headers_raw t = t.dynamic_headers
296296+297297+(* Parse header property name to determine form and :all flag *)
298298+let parse_header_prop name =
299299+ if not (String.length name > 7 && String.sub name 0 7 = "header:") then
300300+ None
301301+ else
302302+ let rest = String.sub name 7 (String.length name - 7) in
303303+ let parts = String.split_on_char ':' rest in
304304+ match parts with
305305+ | [] -> None
306306+ | [_name] -> Some (`Raw, false)
307307+ | [_name; second] ->
308308+ if second = "all" then Some (`Raw, true)
309309+ else (
310310+ match Mail_header.form_of_string second with
311311+ | Some form -> Some (form, false)
312312+ | None -> None
313313+ )
314314+ | [_name; form_str; "all"] ->
315315+ (match Mail_header.form_of_string form_str with
316316+ | Some form -> Some (form, true)
317317+ | None -> None)
318318+ | _ -> None
319319+320320+(* Decode a raw JSON header value into typed header_value *)
321321+let decode_header_value prop_name json =
322322+ match parse_header_prop prop_name with
323323+ | None -> None
324324+ | Some (form, all) ->
325325+ let jsont = Mail_header.header_value_jsont ~form ~all in
326326+ match Jsont.Json.decode' jsont json with
327327+ | Ok v -> Some v
328328+ | Error _ -> None
329329+330330+let get_header t key =
331331+ match List.assoc_opt key t.dynamic_headers with
332332+ | None -> None
333333+ | Some json -> decode_header_value key json
334334+335335+let get_header_string t key =
336336+ match get_header t key with
337337+ | Some (Mail_header.String_single s) -> s
338338+ | _ -> None
339339+340340+let get_header_addresses t key =
341341+ match get_header t key with
342342+ | Some (Mail_header.Addresses_single addrs) -> addrs
343343+ | _ -> None
131344132345let make id blob_id thread_id size received_at mailbox_ids keywords
133346 message_id in_reply_to references sender from to_ cc bcc reply_to
134347 subject sent_at headers body_structure body_values text_body html_body
135135- attachments has_attachment preview =
348348+ attachments has_attachment preview dynamic_headers =
136349 { id; blob_id; thread_id; size; received_at; mailbox_ids; keywords;
137350 message_id; in_reply_to; references; sender; from; to_; cc; bcc;
138351 reply_to; subject; sent_at; headers; body_structure; body_values;
139139- text_body; html_body; attachments; has_attachment; preview }
352352+ text_body; html_body; attachments; has_attachment; preview; dynamic_headers }
353353+354354+(* Helper: null-safe list decoder - treats null as empty list.
355355+ This allows fields that may be null or array to decode successfully. *)
356356+let null_safe_list inner_jsont =
357357+ Jsont.map
358358+ ~dec:(function None -> [] | Some l -> l)
359359+ ~enc:(fun l -> Some l)
360360+ (Jsont.option (Jsont.list inner_jsont))
361361+362362+module String_map = Map.Make(String)
363363+364364+(* Filter unknown members to only keep header:* properties *)
365365+let filter_header_props (unknown : Jsont.json String_map.t) : (string * Jsont.json) list =
366366+ String_map.to_seq unknown
367367+ |> Seq.filter (fun (k, _) -> String.length k > 7 && String.sub k 0 7 = "header:")
368368+ |> List.of_seq
140369141370let jsont =
142371 let kind = "Email" in
143372 let body_values_jsont = Proto_json_map.of_string Mail_body.Value.jsont in
144144- (* subject can be null per RFC 8621 Section 4.1.1 *)
145145- let nullable_string = Jsont.(option string) in
146146- Jsont.Object.map ~kind make
147147- |> Jsont.Object.mem "id" Proto_id.jsont ~enc:id
148148- |> Jsont.Object.mem "blobId" Proto_id.jsont ~enc:blob_id
149149- |> Jsont.Object.mem "threadId" Proto_id.jsont ~enc:thread_id
150150- |> Jsont.Object.mem "size" Proto_int53.Unsigned.jsont ~enc:size
151151- |> Jsont.Object.mem "receivedAt" Proto_date.Utc.jsont ~enc:received_at
152152- |> Jsont.Object.mem "mailboxIds" Proto_json_map.id_to_bool ~enc:mailbox_ids
153153- |> Jsont.Object.mem "keywords" Proto_json_map.string_to_bool ~dec_absent:[] ~enc:keywords
154154- (* Header fields can be absent or null per RFC 8621 *)
155155- |> Jsont.Object.mem "messageId" Jsont.(option (list string))
156156- ~dec_absent:None ~enc_omit:Option.is_none ~enc:message_id
157157- |> Jsont.Object.mem "inReplyTo" Jsont.(option (list string))
158158- ~dec_absent:None ~enc_omit:Option.is_none ~enc:in_reply_to
159159- |> Jsont.Object.mem "references" Jsont.(option (list string))
160160- ~dec_absent:None ~enc_omit:Option.is_none ~enc:references
161161- |> Jsont.Object.mem "sender" Jsont.(option (list Mail_address.jsont))
162162- ~dec_absent:None ~enc_omit:Option.is_none ~enc:sender
163163- |> Jsont.Object.mem "from" Jsont.(option (list Mail_address.jsont))
164164- ~dec_absent:None ~enc_omit:Option.is_none ~enc:from
165165- |> Jsont.Object.mem "to" Jsont.(option (list Mail_address.jsont))
166166- ~dec_absent:None ~enc_omit:Option.is_none ~enc:to_
167167- |> Jsont.Object.mem "cc" Jsont.(option (list Mail_address.jsont))
168168- ~dec_absent:None ~enc_omit:Option.is_none ~enc:cc
169169- |> Jsont.Object.mem "bcc" Jsont.(option (list Mail_address.jsont))
170170- ~dec_absent:None ~enc_omit:Option.is_none ~enc:bcc
171171- |> Jsont.Object.mem "replyTo" Jsont.(option (list Mail_address.jsont))
172172- ~dec_absent:None ~enc_omit:Option.is_none ~enc:reply_to
173173- |> Jsont.Object.mem "subject" nullable_string
174174- ~dec_absent:None ~enc_omit:Option.is_none ~enc:subject
373373+ (* Use null_safe_list for address fields that can be null *)
374374+ let addr_list = null_safe_list Mail_address.jsont in
375375+ let str_list = null_safe_list Jsont.string in
376376+ let part_list = null_safe_list Mail_body.Part.jsont in
377377+ let hdr_list = null_safe_list Mail_header.jsont in
378378+ Jsont.Object.map ~kind (fun id blob_id thread_id size received_at mailbox_ids keywords
379379+ message_id in_reply_to references sender from to_ cc bcc reply_to
380380+ subject sent_at headers body_structure body_values text_body html_body
381381+ attachments has_attachment preview unknown ->
382382+ let dynamic_headers = filter_header_props unknown in
383383+ make id blob_id thread_id size received_at mailbox_ids keywords
384384+ message_id in_reply_to references sender from to_ cc bcc reply_to
385385+ subject sent_at headers body_structure body_values text_body html_body
386386+ attachments has_attachment preview dynamic_headers)
387387+ |> Jsont.Object.opt_mem "id" Proto_id.jsont ~enc:id
388388+ |> Jsont.Object.opt_mem "blobId" Proto_id.jsont ~enc:blob_id
389389+ |> Jsont.Object.opt_mem "threadId" Proto_id.jsont ~enc:thread_id
390390+ |> Jsont.Object.opt_mem "size" Proto_int53.Unsigned.jsont ~enc:size
391391+ |> Jsont.Object.opt_mem "receivedAt" Proto_date.Utc.jsont ~enc:received_at
392392+ |> Jsont.Object.opt_mem "mailboxIds" Proto_json_map.id_to_bool ~enc:mailbox_ids
393393+ |> Jsont.Object.opt_mem "keywords" Proto_json_map.string_to_bool ~enc:keywords
394394+ |> Jsont.Object.opt_mem "messageId" str_list ~enc:message_id
395395+ |> Jsont.Object.opt_mem "inReplyTo" str_list ~enc:in_reply_to
396396+ |> Jsont.Object.opt_mem "references" str_list ~enc:references
397397+ |> Jsont.Object.opt_mem "sender" addr_list ~enc:sender
398398+ |> Jsont.Object.opt_mem "from" addr_list ~enc:from
399399+ |> Jsont.Object.opt_mem "to" addr_list ~enc:to_
400400+ |> Jsont.Object.opt_mem "cc" addr_list ~enc:cc
401401+ |> Jsont.Object.opt_mem "bcc" addr_list ~enc:bcc
402402+ |> Jsont.Object.opt_mem "replyTo" addr_list ~enc:reply_to
403403+ |> Jsont.Object.opt_mem "subject" Jsont.string ~enc:subject
175404 |> Jsont.Object.opt_mem "sentAt" Proto_date.Rfc3339.jsont ~enc:sent_at
176176- |> Jsont.Object.opt_mem "headers" (Jsont.list Mail_header.jsont) ~enc:headers
405405+ |> Jsont.Object.opt_mem "headers" hdr_list ~enc:headers
177406 |> Jsont.Object.opt_mem "bodyStructure" Mail_body.Part.jsont ~enc:body_structure
178407 |> Jsont.Object.opt_mem "bodyValues" body_values_jsont ~enc:body_values
179179- |> Jsont.Object.opt_mem "textBody" (Jsont.list Mail_body.Part.jsont) ~enc:text_body
180180- |> Jsont.Object.opt_mem "htmlBody" (Jsont.list Mail_body.Part.jsont) ~enc:html_body
181181- |> Jsont.Object.opt_mem "attachments" (Jsont.list Mail_body.Part.jsont) ~enc:attachments
182182- |> Jsont.Object.mem "hasAttachment" Jsont.bool ~dec_absent:false ~enc:has_attachment
183183- |> Jsont.Object.mem "preview" Jsont.string ~dec_absent:"" ~enc:preview
408408+ |> Jsont.Object.opt_mem "textBody" part_list ~enc:text_body
409409+ |> Jsont.Object.opt_mem "htmlBody" part_list ~enc:html_body
410410+ |> Jsont.Object.opt_mem "attachments" part_list ~enc:attachments
411411+ |> Jsont.Object.opt_mem "hasAttachment" Jsont.bool ~enc:has_attachment
412412+ |> Jsont.Object.opt_mem "preview" Jsont.string ~enc:preview
413413+ |> Jsont.Object.keep_unknown
414414+ (Jsont.Object.Mems.string_map Jsont.json)
415415+ ~enc:(fun t -> String_map.of_list t.dynamic_headers)
184416 |> Jsont.Object.finish
185417186418module Filter_condition = struct
···216448 none_in_thread_have_keyword; has_keyword; not_keyword; has_attachment;
217449 text; from; to_; cc; bcc; subject; body; header }
218450219219- (* Header filter is encoded as [name] or [name, value] array *)
220451 let header_jsont =
221452 let kind = "HeaderFilter" in
222453 let dec json =
···262493end
263494264495type get_args_extra = {
265265- body_properties : string list option;
496496+ body_properties : body_part_property list option;
266497 fetch_text_body_values : bool;
267498 fetch_html_body_values : bool;
268499 fetch_all_body_values : bool;
269500 max_body_value_bytes : int64 option;
270501}
271502272272-let get_args_extra_make body_properties fetch_text_body_values
273273- fetch_html_body_values fetch_all_body_values max_body_value_bytes =
503503+let get_args_extra ?body_properties ?(fetch_text_body_values=false)
504504+ ?(fetch_html_body_values=false) ?(fetch_all_body_values=false)
505505+ ?max_body_value_bytes () =
274506 { body_properties; fetch_text_body_values; fetch_html_body_values;
275507 fetch_all_body_values; max_body_value_bytes }
508508+509509+let body_part_property_list_jsont =
510510+ Jsont.list (Jsont.map ~kind:"body_part_property"
511511+ ~dec:(fun s -> match body_part_property_of_string s with
512512+ | Some p -> p
513513+ | None -> Jsont.Error.msgf Jsont.Meta.none "Unknown body property: %s" s)
514514+ ~enc:body_part_property_to_string
515515+ Jsont.string)
276516277517let get_args_extra_jsont =
278518 let kind = "Email/get extra args" in
279279- Jsont.Object.map ~kind get_args_extra_make
280280- |> Jsont.Object.opt_mem "bodyProperties" (Jsont.list Jsont.string) ~enc:(fun a -> a.body_properties)
519519+ Jsont.Object.map ~kind (fun body_properties fetch_text_body_values
520520+ fetch_html_body_values fetch_all_body_values max_body_value_bytes ->
521521+ { body_properties; fetch_text_body_values; fetch_html_body_values;
522522+ fetch_all_body_values; max_body_value_bytes })
523523+ |> Jsont.Object.opt_mem "bodyProperties" body_part_property_list_jsont
524524+ ~enc:(fun a -> a.body_properties)
281525 |> Jsont.Object.mem "fetchTextBodyValues" Jsont.bool ~dec_absent:false
282526 ~enc:(fun a -> a.fetch_text_body_values) ~enc_omit:(fun b -> not b)
283527 |> Jsont.Object.mem "fetchHTMLBodyValues" Jsont.bool ~dec_absent:false
284528 ~enc:(fun a -> a.fetch_html_body_values) ~enc_omit:(fun b -> not b)
285529 |> Jsont.Object.mem "fetchAllBodyValues" Jsont.bool ~dec_absent:false
286530 ~enc:(fun a -> a.fetch_all_body_values) ~enc_omit:(fun b -> not b)
287287- |> Jsont.Object.opt_mem "maxBodyValueBytes" Proto_int53.Unsigned.jsont ~enc:(fun a -> a.max_body_value_bytes)
531531+ |> Jsont.Object.opt_mem "maxBodyValueBytes" Proto_int53.Unsigned.jsont
532532+ ~enc:(fun a -> a.max_body_value_bytes)
288533 |> Jsont.Object.finish
+176-20
lib/mail/mail_email.mli
···133133 if no color bits are set (defaults to red when $flagged is set). *)
134134end
135135136136+(** {1 Email Properties}
137137+138138+ Polymorphic variants for type-safe property selection in Email/get requests.
139139+ These correspond to the properties defined in RFC 8621 Section 4.1. *)
140140+141141+(** Metadata properties (RFC 8621 Section 4.1.1).
142142+ These represent data about the message in the mail store. *)
143143+type metadata_property = [
144144+ | `Id
145145+ | `Blob_id
146146+ | `Thread_id
147147+ | `Mailbox_ids
148148+ | `Keywords
149149+ | `Size
150150+ | `Received_at
151151+]
152152+153153+(** Convenience header properties (RFC 8621 Section 4.1.3).
154154+ These are shortcuts for specific header:*:form properties. *)
155155+type header_convenience_property = [
156156+ | `Message_id (** = header:Message-ID:asMessageIds *)
157157+ | `In_reply_to (** = header:In-Reply-To:asMessageIds *)
158158+ | `References (** = header:References:asMessageIds *)
159159+ | `Sender (** = header:Sender:asAddresses *)
160160+ | `From (** = header:From:asAddresses *)
161161+ | `To (** = header:To:asAddresses *)
162162+ | `Cc (** = header:Cc:asAddresses *)
163163+ | `Bcc (** = header:Bcc:asAddresses *)
164164+ | `Reply_to (** = header:Reply-To:asAddresses *)
165165+ | `Subject (** = header:Subject:asText *)
166166+ | `Sent_at (** = header:Date:asDate *)
167167+ | `Headers (** All headers in raw form *)
168168+]
169169+170170+(** Body properties (RFC 8621 Section 4.1.4).
171171+ These represent the message body structure and content. *)
172172+type body_property = [
173173+ | `Body_structure
174174+ | `Body_values
175175+ | `Text_body
176176+ | `Html_body
177177+ | `Attachments
178178+ | `Has_attachment
179179+ | `Preview
180180+]
181181+182182+(** All standard Email properties. *)
183183+type standard_property = [
184184+ | metadata_property
185185+ | header_convenience_property
186186+ | body_property
187187+]
188188+189189+(** A dynamic header property request.
190190+ Use {!Mail_header.header_property} for type-safe construction. *)
191191+type header_property = [ `Header of Mail_header.header_property ]
192192+193193+(** Any Email property - standard or dynamic header. *)
194194+type property = [ standard_property | header_property ]
195195+196196+val property_to_string : [< property ] -> string
197197+(** Convert a property to its wire name (e.g., [`From] -> "from"). *)
198198+199199+val property_of_string : string -> property option
200200+(** Parse a property name. Returns [None] for unrecognized properties.
201201+ Handles both standard properties and header:* properties. *)
202202+203203+val standard_property_of_string : string -> standard_property option
204204+(** Parse only standard property names (not header:* properties). *)
205205+206206+(** {1 Body Part Properties}
207207+208208+ Properties that can be requested for EmailBodyPart objects
209209+ via the [bodyProperties] argument. *)
210210+211211+type body_part_property = [
212212+ | `Part_id
213213+ | `Blob_id
214214+ | `Size
215215+ | `Part_headers (** Named [headers] in the wire format *)
216216+ | `Name
217217+ | `Type
218218+ | `Charset
219219+ | `Disposition
220220+ | `Cid
221221+ | `Language
222222+ | `Location
223223+ | `Sub_parts
224224+]
225225+226226+val body_part_property_to_string : [< body_part_property ] -> string
227227+(** Convert a body part property to its wire name. *)
228228+229229+val body_part_property_of_string : string -> body_part_property option
230230+(** Parse a body part property name. *)
231231+136232(** {1 Email Object} *)
137233138234type t = {
139235 (* Metadata - server-set, immutable *)
140140- id : Proto_id.t;
141141- blob_id : Proto_id.t;
142142- thread_id : Proto_id.t;
143143- size : int64;
144144- received_at : Ptime.t;
236236+ id : Proto_id.t option;
237237+ blob_id : Proto_id.t option;
238238+ thread_id : Proto_id.t option;
239239+ size : int64 option;
240240+ received_at : Ptime.t option;
145241146242 (* Metadata - mutable *)
147147- mailbox_ids : (Proto_id.t * bool) list;
148148- keywords : (string * bool) list;
243243+ mailbox_ids : (Proto_id.t * bool) list option;
244244+ keywords : (string * bool) list option;
149245150246 (* Parsed headers *)
151247 message_id : string list option;
···169265 text_body : Mail_body.Part.t list option;
170266 html_body : Mail_body.Part.t list option;
171267 attachments : Mail_body.Part.t list option;
172172- has_attachment : bool;
173173- preview : string;
268268+ has_attachment : bool option;
269269+ preview : string option;
270270+271271+ (* Dynamic header properties - stored as raw JSON for lazy decoding *)
272272+ dynamic_headers : (string * Jsont.json) list;
273273+ (** Raw header values from [header:*] property requests.
274274+ The key is the full property name (e.g., "header:X-Custom:asText").
275275+ Use {!decode_header_value} to parse into typed values. *)
174276}
175277176176-val id : t -> Proto_id.t
177177-val blob_id : t -> Proto_id.t
178178-val thread_id : t -> Proto_id.t
179179-val size : t -> int64
180180-val received_at : t -> Ptime.t
181181-val mailbox_ids : t -> (Proto_id.t * bool) list
182182-val keywords : t -> (string * bool) list
278278+(** {2 Accessors}
279279+280280+ All accessors return [option] types since the response only includes
281281+ properties that were requested. *)
282282+283283+val id : t -> Proto_id.t option
284284+val blob_id : t -> Proto_id.t option
285285+val thread_id : t -> Proto_id.t option
286286+val size : t -> int64 option
287287+val received_at : t -> Ptime.t option
288288+val mailbox_ids : t -> (Proto_id.t * bool) list option
289289+val keywords : t -> (string * bool) list option
183290val message_id : t -> string list option
184291val in_reply_to : t -> string list option
185292val references : t -> string list option
···197304val text_body : t -> Mail_body.Part.t list option
198305val html_body : t -> Mail_body.Part.t list option
199306val attachments : t -> Mail_body.Part.t list option
200200-val has_attachment : t -> bool
201201-val preview : t -> string
307307+val has_attachment : t -> bool option
308308+val preview : t -> string option
309309+val dynamic_headers_raw : t -> (string * Jsont.json) list
310310+(** Get raw dynamic headers. Use {!decode_header_value} to parse them. *)
311311+312312+(** {2 Dynamic Header Decoding} *)
313313+314314+val decode_header_value : string -> Jsont.json -> Mail_header.header_value option
315315+(** [decode_header_value prop_name json] decodes a raw JSON value into a typed
316316+ header value based on the property name. The property name determines the form:
317317+ - [header:Name] or [header:Name:all] -> Raw/Text (String_single/String_all)
318318+ - [header:Name:asText] -> Text (String_single)
319319+ - [header:Name:asAddresses] -> Addresses (Addresses_single)
320320+ - [header:Name:asGroupedAddresses] -> Grouped (Grouped_single)
321321+ - [header:Name:asMessageIds] -> MessageIds (Strings_single)
322322+ - [header:Name:asDate] -> Date (Date_single)
323323+ - [header:Name:asURLs] -> URLs (Strings_single)
324324+ Returns [None] if the property name is invalid or decoding fails. *)
325325+326326+val get_header : t -> string -> Mail_header.header_value option
327327+(** [get_header email key] looks up and decodes a dynamic header by its full
328328+ property name. E.g., [get_header email "header:X-Custom:asText"]. *)
329329+330330+val get_header_string : t -> string -> string option
331331+(** [get_header_string email key] looks up a string header value.
332332+ Returns [None] if not found or if the value is not a string type. *)
333333+334334+val get_header_addresses : t -> string -> Mail_address.t list option
335335+(** [get_header_addresses email key] looks up an addresses header value.
336336+ Returns [None] if not found or if the value is not an addresses type. *)
202337203338val jsont : t Jsont.t
339339+(** Permissive JSON codec that handles any subset of properties.
340340+ Unknown [header:*] properties are decoded into {!dynamic_headers}. *)
204341205342(** {1 Email Filter Conditions} *)
206343···233370234371(** {1 Email/get Arguments} *)
235372236236-(** Extra arguments for Email/get beyond standard /get. *)
373373+(** Extra arguments for Email/get beyond standard /get.
374374+375375+ Note: The standard [properties] argument from {!Proto_method.get_args}
376376+ should use {!property} variants converted via {!property_to_string}. *)
237377type get_args_extra = {
238238- body_properties : string list option;
378378+ body_properties : body_part_property list option;
379379+ (** Properties to fetch for each EmailBodyPart.
380380+ If omitted, defaults to all properties. *)
239381 fetch_text_body_values : bool;
382382+ (** If [true], fetch body values for text/* parts in textBody. *)
240383 fetch_html_body_values : bool;
384384+ (** If [true], fetch body values for text/* parts in htmlBody. *)
241385 fetch_all_body_values : bool;
386386+ (** If [true], fetch body values for all text/* parts. *)
242387 max_body_value_bytes : int64 option;
388388+ (** Maximum size of body values to return. Larger values are truncated. *)
243389}
390390+391391+val get_args_extra :
392392+ ?body_properties:body_part_property list ->
393393+ ?fetch_text_body_values:bool ->
394394+ ?fetch_html_body_values:bool ->
395395+ ?fetch_all_body_values:bool ->
396396+ ?max_body_value_bytes:int64 ->
397397+ unit ->
398398+ get_args_extra
399399+(** Convenience constructor with sensible defaults. *)
244400245401val get_args_extra_jsont : get_args_extra Jsont.t
+332-1
lib/mail/mail_header.ml
···2222 |> Jsont.Object.mem "value" Jsont.string ~enc:value
2323 |> Jsont.Object.finish
24242525-(* Header parsed forms - these are used with header:Name:form properties *)
2525+(* Header categories *)
2626+2727+type address_header = [
2828+ | `From
2929+ | `Sender
3030+ | `Reply_to
3131+ | `To
3232+ | `Cc
3333+ | `Bcc
3434+ | `Resent_from
3535+ | `Resent_sender
3636+ | `Resent_reply_to
3737+ | `Resent_to
3838+ | `Resent_cc
3939+ | `Resent_bcc
4040+]
4141+4242+type message_id_header = [
4343+ | `Message_id
4444+ | `In_reply_to
4545+ | `References
4646+ | `Resent_message_id
4747+]
4848+4949+type date_header = [
5050+ | `Date
5151+ | `Resent_date
5252+]
5353+5454+type url_header = [
5555+ | `List_help
5656+ | `List_unsubscribe
5757+ | `List_subscribe
5858+ | `List_post
5959+ | `List_owner
6060+ | `List_archive
6161+]
6262+6363+type text_header = [
6464+ | `Subject
6565+ | `Comments
6666+ | `Keywords
6767+ | `List_id
6868+]
6969+7070+type standard_header = [
7171+ | address_header
7272+ | message_id_header
7373+ | date_header
7474+ | url_header
7575+ | text_header
7676+]
7777+7878+type custom_header = [ `Custom of string ]
7979+8080+type any_header = [ standard_header | custom_header ]
8181+8282+let standard_header_to_string : [< standard_header ] -> string = function
8383+ | `From -> "From"
8484+ | `Sender -> "Sender"
8585+ | `Reply_to -> "Reply-To"
8686+ | `To -> "To"
8787+ | `Cc -> "Cc"
8888+ | `Bcc -> "Bcc"
8989+ | `Resent_from -> "Resent-From"
9090+ | `Resent_sender -> "Resent-Sender"
9191+ | `Resent_reply_to -> "Resent-Reply-To"
9292+ | `Resent_to -> "Resent-To"
9393+ | `Resent_cc -> "Resent-Cc"
9494+ | `Resent_bcc -> "Resent-Bcc"
9595+ | `Message_id -> "Message-ID"
9696+ | `In_reply_to -> "In-Reply-To"
9797+ | `References -> "References"
9898+ | `Resent_message_id -> "Resent-Message-ID"
9999+ | `Date -> "Date"
100100+ | `Resent_date -> "Resent-Date"
101101+ | `List_help -> "List-Help"
102102+ | `List_unsubscribe -> "List-Unsubscribe"
103103+ | `List_subscribe -> "List-Subscribe"
104104+ | `List_post -> "List-Post"
105105+ | `List_owner -> "List-Owner"
106106+ | `List_archive -> "List-Archive"
107107+ | `Subject -> "Subject"
108108+ | `Comments -> "Comments"
109109+ | `Keywords -> "Keywords"
110110+ | `List_id -> "List-Id"
111111+112112+let standard_header_of_string s : standard_header option =
113113+ match String.lowercase_ascii s with
114114+ | "from" -> Some `From
115115+ | "sender" -> Some `Sender
116116+ | "reply-to" -> Some `Reply_to
117117+ | "to" -> Some `To
118118+ | "cc" -> Some `Cc
119119+ | "bcc" -> Some `Bcc
120120+ | "resent-from" -> Some `Resent_from
121121+ | "resent-sender" -> Some `Resent_sender
122122+ | "resent-reply-to" -> Some `Resent_reply_to
123123+ | "resent-to" -> Some `Resent_to
124124+ | "resent-cc" -> Some `Resent_cc
125125+ | "resent-bcc" -> Some `Resent_bcc
126126+ | "message-id" -> Some `Message_id
127127+ | "in-reply-to" -> Some `In_reply_to
128128+ | "references" -> Some `References
129129+ | "resent-message-id" -> Some `Resent_message_id
130130+ | "date" -> Some `Date
131131+ | "resent-date" -> Some `Resent_date
132132+ | "list-help" -> Some `List_help
133133+ | "list-unsubscribe" -> Some `List_unsubscribe
134134+ | "list-subscribe" -> Some `List_subscribe
135135+ | "list-post" -> Some `List_post
136136+ | "list-owner" -> Some `List_owner
137137+ | "list-archive" -> Some `List_archive
138138+ | "subject" -> Some `Subject
139139+ | "comments" -> Some `Comments
140140+ | "keywords" -> Some `Keywords
141141+ | "list-id" -> Some `List_id
142142+ | _ -> None
143143+144144+let any_header_to_string : [< any_header ] -> string = function
145145+ | `Custom s -> s
146146+ | #standard_header as h -> standard_header_to_string h
147147+148148+(* Header parsed forms *)
149149+150150+type form = [
151151+ | `Raw
152152+ | `Text
153153+ | `Addresses
154154+ | `Grouped_addresses
155155+ | `Message_ids
156156+ | `Date
157157+ | `Urls
158158+]
159159+160160+let form_to_string : [< form ] -> string = function
161161+ | `Raw -> ""
162162+ | `Text -> "asText"
163163+ | `Addresses -> "asAddresses"
164164+ | `Grouped_addresses -> "asGroupedAddresses"
165165+ | `Message_ids -> "asMessageIds"
166166+ | `Date -> "asDate"
167167+ | `Urls -> "asURLs"
168168+169169+let form_of_string s : form option =
170170+ match s with
171171+ | "" -> Some `Raw
172172+ | "asText" -> Some `Text
173173+ | "asAddresses" -> Some `Addresses
174174+ | "asGroupedAddresses" -> Some `Grouped_addresses
175175+ | "asMessageIds" -> Some `Message_ids
176176+ | "asDate" -> Some `Date
177177+ | "asURLs" -> Some `Urls
178178+ | _ -> None
179179+180180+(* Header property requests *)
181181+182182+type header_property =
183183+ | Raw of { name : string; all : bool }
184184+ | Text of { header : [ text_header | custom_header ]; all : bool }
185185+ | Addresses of { header : [ address_header | custom_header ]; all : bool }
186186+ | Grouped_addresses of { header : [ address_header | custom_header ]; all : bool }
187187+ | Message_ids of { header : [ message_id_header | custom_header ]; all : bool }
188188+ | Date of { header : [ date_header | custom_header ]; all : bool }
189189+ | Urls of { header : [ url_header | custom_header ]; all : bool }
190190+191191+let header_name_of_property : header_property -> string = function
192192+ | Raw { name; _ } -> name
193193+ | Text { header; _ } -> any_header_to_string (header :> any_header)
194194+ | Addresses { header; _ } -> any_header_to_string (header :> any_header)
195195+ | Grouped_addresses { header; _ } -> any_header_to_string (header :> any_header)
196196+ | Message_ids { header; _ } -> any_header_to_string (header :> any_header)
197197+ | Date { header; _ } -> any_header_to_string (header :> any_header)
198198+ | Urls { header; _ } -> any_header_to_string (header :> any_header)
199199+200200+let header_property_all : header_property -> bool = function
201201+ | Raw { all; _ } -> all
202202+ | Text { all; _ } -> all
203203+ | Addresses { all; _ } -> all
204204+ | Grouped_addresses { all; _ } -> all
205205+ | Message_ids { all; _ } -> all
206206+ | Date { all; _ } -> all
207207+ | Urls { all; _ } -> all
208208+209209+let header_property_form : header_property -> form = function
210210+ | Raw _ -> `Raw
211211+ | Text _ -> `Text
212212+ | Addresses _ -> `Addresses
213213+ | Grouped_addresses _ -> `Grouped_addresses
214214+ | Message_ids _ -> `Message_ids
215215+ | Date _ -> `Date
216216+ | Urls _ -> `Urls
217217+218218+let header_property_to_string prop =
219219+ let name = header_name_of_property prop in
220220+ let form = form_to_string (header_property_form prop) in
221221+ let all_suffix = if header_property_all prop then ":all" else "" in
222222+ let form_suffix = if form = "" then "" else ":" ^ form in
223223+ "header:" ^ name ^ form_suffix ^ all_suffix
224224+225225+let header_property_of_string s : header_property option =
226226+ if not (String.length s > 7 && String.sub s 0 7 = "header:") then
227227+ None
228228+ else
229229+ let rest = String.sub s 7 (String.length s - 7) in
230230+ (* Parse the parts: name[:form][:all] *)
231231+ let parts = String.split_on_char ':' rest in
232232+ match parts with
233233+ | [] -> None
234234+ | [name] ->
235235+ Some (Raw { name; all = false })
236236+ | [name; second] ->
237237+ if second = "all" then
238238+ Some (Raw { name; all = true })
239239+ else begin
240240+ match form_of_string second with
241241+ | None -> None
242242+ | Some `Raw -> Some (Raw { name; all = false })
243243+ | Some `Text -> Some (Text { header = `Custom name; all = false })
244244+ | Some `Addresses -> Some (Addresses { header = `Custom name; all = false })
245245+ | Some `Grouped_addresses -> Some (Grouped_addresses { header = `Custom name; all = false })
246246+ | Some `Message_ids -> Some (Message_ids { header = `Custom name; all = false })
247247+ | Some `Date -> Some (Date { header = `Custom name; all = false })
248248+ | Some `Urls -> Some (Urls { header = `Custom name; all = false })
249249+ end
250250+ | [name; form_str; "all"] ->
251251+ begin match form_of_string form_str with
252252+ | None -> None
253253+ | Some `Raw -> Some (Raw { name; all = true })
254254+ | Some `Text -> Some (Text { header = `Custom name; all = true })
255255+ | Some `Addresses -> Some (Addresses { header = `Custom name; all = true })
256256+ | Some `Grouped_addresses -> Some (Grouped_addresses { header = `Custom name; all = true })
257257+ | Some `Message_ids -> Some (Message_ids { header = `Custom name; all = true })
258258+ | Some `Date -> Some (Date { header = `Custom name; all = true })
259259+ | Some `Urls -> Some (Urls { header = `Custom name; all = true })
260260+ end
261261+ | _ -> None
262262+263263+(* Convenience constructors *)
264264+265265+let raw ?(all=false) name = Raw { name; all }
266266+267267+let text ?(all=false) header = Text { header; all }
268268+269269+let addresses ?(all=false) header = Addresses { header; all }
270270+271271+let grouped_addresses ?(all=false) header = Grouped_addresses { header; all }
272272+273273+let message_ids ?(all=false) header = Message_ids { header; all }
274274+275275+let date ?(all=false) header = Date { header; all }
276276+277277+let urls ?(all=false) header = Urls { header; all }
278278+279279+(* Header values in responses *)
280280+281281+type header_value =
282282+ | String_single of string option
283283+ | String_all of string list
284284+ | Addresses_single of Mail_address.t list option
285285+ | Addresses_all of Mail_address.t list list
286286+ | Grouped_single of Mail_address.Group.t list option
287287+ | Grouped_all of Mail_address.Group.t list list
288288+ | Date_single of Ptime.t option
289289+ | Date_all of Ptime.t option list
290290+ | Strings_single of string list option
291291+ | Strings_all of string list option list
292292+293293+let header_value_jsont ~form ~all : header_value Jsont.t =
294294+ match form, all with
295295+ | (`Raw | `Text), false ->
296296+ Jsont.map
297297+ ~dec:(fun s -> String_single s)
298298+ ~enc:(function String_single s -> s | _ -> None)
299299+ (Jsont.option Jsont.string)
300300+ | (`Raw | `Text), true ->
301301+ Jsont.map
302302+ ~dec:(fun l -> String_all l)
303303+ ~enc:(function String_all l -> l | _ -> [])
304304+ (Jsont.list Jsont.string)
305305+ | `Addresses, false ->
306306+ Jsont.map
307307+ ~dec:(fun l -> Addresses_single l)
308308+ ~enc:(function Addresses_single l -> l | _ -> None)
309309+ (Jsont.option (Jsont.list Mail_address.jsont))
310310+ | `Addresses, true ->
311311+ Jsont.map
312312+ ~dec:(fun l -> Addresses_all l)
313313+ ~enc:(function Addresses_all l -> l | _ -> [])
314314+ (Jsont.list (Jsont.list Mail_address.jsont))
315315+ | `Grouped_addresses, false ->
316316+ Jsont.map
317317+ ~dec:(fun l -> Grouped_single l)
318318+ ~enc:(function Grouped_single l -> l | _ -> None)
319319+ (Jsont.option (Jsont.list Mail_address.Group.jsont))
320320+ | `Grouped_addresses, true ->
321321+ Jsont.map
322322+ ~dec:(fun l -> Grouped_all l)
323323+ ~enc:(function Grouped_all l -> l | _ -> [])
324324+ (Jsont.list (Jsont.list Mail_address.Group.jsont))
325325+ | `Message_ids, false ->
326326+ Jsont.map
327327+ ~dec:(fun l -> Strings_single l)
328328+ ~enc:(function Strings_single l -> l | _ -> None)
329329+ (Jsont.option (Jsont.list Jsont.string))
330330+ | `Message_ids, true ->
331331+ Jsont.map
332332+ ~dec:(fun l -> Strings_all l)
333333+ ~enc:(function Strings_all l -> l | _ -> [])
334334+ (Jsont.list (Jsont.option (Jsont.list Jsont.string)))
335335+ | `Date, false ->
336336+ Jsont.map
337337+ ~dec:(fun t -> Date_single t)
338338+ ~enc:(function Date_single t -> t | _ -> None)
339339+ (Jsont.option Proto_date.Rfc3339.jsont)
340340+ | `Date, true ->
341341+ Jsont.map
342342+ ~dec:(fun l -> Date_all l)
343343+ ~enc:(function Date_all l -> l | _ -> [])
344344+ (Jsont.list (Jsont.option Proto_date.Rfc3339.jsont))
345345+ | `Urls, false ->
346346+ Jsont.map
347347+ ~dec:(fun l -> Strings_single l)
348348+ ~enc:(function Strings_single l -> l | _ -> None)
349349+ (Jsont.option (Jsont.list Jsont.string))
350350+ | `Urls, true ->
351351+ Jsont.map
352352+ ~dec:(fun l -> Strings_all l)
353353+ ~enc:(function Strings_all l -> l | _ -> [])
354354+ (Jsont.list (Jsont.option (Jsont.list Jsont.string)))
355355+356356+(* Low-level JSON codecs *)
2635727358let raw_jsont = Jsont.string
28359
+234-2
lib/mail/mail_header.mli
···33 SPDX-License-Identifier: ISC
44 ---------------------------------------------------------------------------*)
5566-(** Email header types as defined in RFC 8621 Section 4.1.2
66+(** Email header types as defined in RFC 8621 Section 4.1.2
7788 @canonical Jmap.Proto.Email_header *)
99···24242525val jsont : t Jsont.t
26262727+(** {1 Header Categories}
2828+2929+ RFC 8621 Section 4.1.2 restricts which parsed forms can be used with
3030+ which headers. These polymorphic variant types encode those restrictions
3131+ at the type level.
3232+3333+ Each category corresponds to headers that share the same allowed forms:
3434+ - Address headers: can use [Addresses] and [Grouped_addresses] forms
3535+ - Message-ID headers: can use [Message_ids] form
3636+ - Date headers: can use [Date] form
3737+ - URL headers: can use [Urls] form
3838+ - Text headers: can use [Text] form
3939+ - All headers can use [Raw] form
4040+ - Custom headers (not in RFC 5322/2369) can use any form *)
4141+4242+(** Headers that allow the [Addresses] and [Grouped_addresses] forms.
4343+ These are address-list headers per RFC 5322. *)
4444+type address_header = [
4545+ | `From
4646+ | `Sender
4747+ | `Reply_to
4848+ | `To
4949+ | `Cc
5050+ | `Bcc
5151+ | `Resent_from
5252+ | `Resent_sender
5353+ | `Resent_reply_to
5454+ | `Resent_to
5555+ | `Resent_cc
5656+ | `Resent_bcc
5757+]
5858+5959+(** Headers that allow the [Message_ids] form.
6060+ These contain msg-id values per RFC 5322. *)
6161+type message_id_header = [
6262+ | `Message_id
6363+ | `In_reply_to
6464+ | `References
6565+ | `Resent_message_id
6666+]
6767+6868+(** Headers that allow the [Date] form.
6969+ These contain date-time values per RFC 5322. *)
7070+type date_header = [
7171+ | `Date
7272+ | `Resent_date
7373+]
7474+7575+(** Headers that allow the [Urls] form.
7676+ These are list-* headers per RFC 2369. *)
7777+type url_header = [
7878+ | `List_help
7979+ | `List_unsubscribe
8080+ | `List_subscribe
8181+ | `List_post
8282+ | `List_owner
8383+ | `List_archive
8484+]
8585+8686+(** Headers that allow the [Text] form.
8787+ These contain unstructured or phrase content. *)
8888+type text_header = [
8989+ | `Subject
9090+ | `Comments
9191+ | `Keywords
9292+ | `List_id
9393+]
9494+9595+(** All standard headers defined in RFC 5322 and RFC 2369. *)
9696+type standard_header = [
9797+ | address_header
9898+ | message_id_header
9999+ | date_header
100100+ | url_header
101101+ | text_header
102102+]
103103+104104+(** A custom header not defined in RFC 5322 or RFC 2369.
105105+ Custom headers can use any parsed form. *)
106106+type custom_header = [ `Custom of string ]
107107+108108+(** Any header - standard or custom. *)
109109+type any_header = [ standard_header | custom_header ]
110110+111111+(** {2 Header Name Conversion} *)
112112+113113+val standard_header_to_string : [< standard_header ] -> string
114114+(** Convert a standard header variant to its wire name (e.g., [`From] -> "From"). *)
115115+116116+val standard_header_of_string : string -> standard_header option
117117+(** Parse a header name to a standard header variant, case-insensitive.
118118+ Returns [None] for non-standard headers. *)
119119+120120+val any_header_to_string : [< any_header ] -> string
121121+(** Convert any header variant to its wire name. *)
122122+27123(** {1 Header Parsed Forms}
2812429125 RFC 8621 defines several parsed forms for headers.
3030- These can be requested via the header:Name:form properties. *)
126126+ These can be requested via the [header:Name:form] properties. *)
127127+128128+(** The parsed form to request for a header value. *)
129129+type form = [
130130+ | `Raw (** Raw octets, available for all headers *)
131131+ | `Text (** Decoded text, for text headers or custom *)
132132+ | `Addresses (** Flat address list, for address headers or custom *)
133133+ | `Grouped_addresses (** Address list with groups, for address headers or custom *)
134134+ | `Message_ids (** List of message-id strings, for message-id headers or custom *)
135135+ | `Date (** Parsed date, for date headers or custom *)
136136+ | `Urls (** List of URLs, for url headers or custom *)
137137+]
138138+139139+val form_to_string : [< form ] -> string
140140+(** Convert form to wire suffix (e.g., [`Addresses] -> "asAddresses").
141141+ [`Raw] returns the empty string (raw is the default). *)
142142+143143+val form_of_string : string -> form option
144144+(** Parse a form suffix (e.g., "asAddresses" -> [`Addresses]).
145145+ Empty string returns [`Raw]. *)
146146+147147+(** {1 Header Property Requests}
148148+149149+ Type-safe construction of [header:Name:form:all] property strings.
150150+ The GADT ensures that only valid form/header combinations are allowed. *)
151151+152152+(** A header property request with type-safe form selection.
153153+154154+ The type parameter encodes what forms are allowed:
155155+ - Address headers allow [Addresses] and [Grouped_addresses]
156156+ - Message-ID headers allow [Message_ids]
157157+ - Date headers allow [Date]
158158+ - URL headers allow [Urls]
159159+ - Text headers allow [Text]
160160+ - All headers allow [Raw]
161161+ - Custom headers allow any form *)
162162+type header_property =
163163+ | Raw of { name : string; all : bool }
164164+ (** Raw form, available for any header. *)
165165+166166+ | Text of { header : [ text_header | custom_header ]; all : bool }
167167+ (** Text form, for text headers or custom. *)
168168+169169+ | Addresses of { header : [ address_header | custom_header ]; all : bool }
170170+ (** Addresses form, for address headers or custom. *)
171171+172172+ | Grouped_addresses of { header : [ address_header | custom_header ]; all : bool }
173173+ (** GroupedAddresses form, for address headers or custom. *)
174174+175175+ | Message_ids of { header : [ message_id_header | custom_header ]; all : bool }
176176+ (** MessageIds form, for message-id headers or custom. *)
177177+178178+ | Date of { header : [ date_header | custom_header ]; all : bool }
179179+ (** Date form, for date headers or custom. *)
180180+181181+ | Urls of { header : [ url_header | custom_header ]; all : bool }
182182+ (** URLs form, for URL headers or custom. *)
183183+184184+val header_property_to_string : header_property -> string
185185+(** Convert a header property request to wire format.
186186+ E.g., [Addresses { header = `From; all = true }] -> "header:From:asAddresses:all" *)
187187+188188+val header_property_of_string : string -> header_property option
189189+(** Parse a header property string.
190190+ Returns [None] if the string doesn't match [header:*] format. *)
191191+192192+(** {2 Convenience Constructors} *)
193193+194194+val raw : ?all:bool -> string -> header_property
195195+(** [raw ?all name] creates a raw header property request. *)
196196+197197+val text : ?all:bool -> [ text_header | custom_header ] -> header_property
198198+(** [text ?all header] creates a text header property request. *)
199199+200200+val addresses : ?all:bool -> [ address_header | custom_header ] -> header_property
201201+(** [addresses ?all header] creates an addresses header property request. *)
202202+203203+val grouped_addresses : ?all:bool -> [ address_header | custom_header ] -> header_property
204204+(** [grouped_addresses ?all header] creates a grouped addresses header property request. *)
205205+206206+val message_ids : ?all:bool -> [ message_id_header | custom_header ] -> header_property
207207+(** [message_ids ?all header] creates a message-ids header property request. *)
208208+209209+val date : ?all:bool -> [ date_header | custom_header ] -> header_property
210210+(** [date ?all header] creates a date header property request. *)
211211+212212+val urls : ?all:bool -> [ url_header | custom_header ] -> header_property
213213+(** [urls ?all header] creates a URLs header property request. *)
214214+215215+(** {1 Header Values in Responses}
216216+217217+ When fetching dynamic headers, the response value type depends on the
218218+ requested form. This type captures all possible response shapes. *)
219219+220220+(** A header value from the response.
221221+222222+ The variant encodes both the form and whether [:all] was requested:
223223+ - [*_single] variants: value of the last header instance, or [None] if absent
224224+ - [*_all] variants: list of values for all instances, empty if absent *)
225225+type header_value =
226226+ | String_single of string option
227227+ (** Raw or Text form, single instance. *)
228228+229229+ | String_all of string list
230230+ (** Raw or Text form, all instances. *)
231231+232232+ | Addresses_single of Mail_address.t list option
233233+ (** Addresses form, single instance. *)
234234+235235+ | Addresses_all of Mail_address.t list list
236236+ (** Addresses form, all instances. *)
237237+238238+ | Grouped_single of Mail_address.Group.t list option
239239+ (** GroupedAddresses form, single instance. *)
240240+241241+ | Grouped_all of Mail_address.Group.t list list
242242+ (** GroupedAddresses form, all instances. *)
243243+244244+ | Date_single of Ptime.t option
245245+ (** Date form, single instance. *)
246246+247247+ | Date_all of Ptime.t option list
248248+ (** Date form, all instances. *)
249249+250250+ | Strings_single of string list option
251251+ (** MessageIds or URLs form, single instance. *)
252252+253253+ | Strings_all of string list option list
254254+ (** MessageIds or URLs form, all instances. *)
255255+256256+val header_value_jsont : form:form -> all:bool -> header_value Jsont.t
257257+(** [header_value_jsont ~form ~all] returns a JSON codec for header values
258258+ with the given form and multiplicity. *)
259259+260260+(** {1 Low-level JSON Codecs}
261261+262262+ These codecs are used internally and for custom header processing. *)
3126332264(** The raw form - header value as-is. *)
33265val raw_jsont : string Jsont.t
···33 SPDX-License-Identifier: ISC
44 ---------------------------------------------------------------------------*)
5566-(** EmailSubmission type as defined in RFC 8621 Section 7
66+(** EmailSubmission type as defined in RFC 8621 Section 7
7788 @canonical Jmap.Proto.Submission *)
99+1010+(** {1 EmailSubmission Properties}
1111+1212+ Polymorphic variants for type-safe property selection in EmailSubmission/get requests.
1313+ These correspond to the properties defined in RFC 8621 Section 7. *)
1414+1515+(** All EmailSubmission properties that can be requested. *)
1616+type property = [
1717+ | `Id
1818+ | `Identity_id
1919+ | `Email_id
2020+ | `Thread_id
2121+ | `Envelope
2222+ | `Send_at
2323+ | `Undo_status
2424+ | `Delivery_status
2525+ | `Dsn_blob_ids
2626+ | `Mdn_blob_ids
2727+]
2828+2929+val property_to_string : [< property ] -> string
3030+(** Convert a property to its wire name (e.g., [`Identity_id] -> "identityId"). *)
3131+3232+val property_of_string : string -> property option
3333+(** Parse a property name, case-sensitive. *)
9341035(** {1 Address} *)
1136···86111(** {1 EmailSubmission} *)
8711288113type t = {
8989- id : Proto_id.t;
114114+ id : Proto_id.t option;
90115 (** Server-assigned submission id. *)
9191- identity_id : Proto_id.t;
116116+ identity_id : Proto_id.t option;
92117 (** The identity used to send. *)
9393- email_id : Proto_id.t;
118118+ email_id : Proto_id.t option;
94119 (** The email that was submitted. *)
9595- thread_id : Proto_id.t;
120120+ thread_id : Proto_id.t option;
96121 (** The thread of the submitted email. *)
97122 envelope : Envelope.t option;
98123 (** The envelope used, if different from email headers. *)
9999- send_at : Ptime.t;
124124+ send_at : Ptime.t option;
100125 (** When the email was/will be sent. *)
101101- undo_status : undo_status;
126126+ undo_status : undo_status option;
102127 (** Whether sending can be undone. *)
103128 delivery_status : (string * Delivery_status.t) list option;
104129 (** Delivery status per recipient. *)
105105- dsn_blob_ids : Proto_id.t list;
130130+ dsn_blob_ids : Proto_id.t list option;
106131 (** Blob ids of received DSN messages. *)
107107- mdn_blob_ids : Proto_id.t list;
132132+ mdn_blob_ids : Proto_id.t list option;
108133 (** Blob ids of received MDN messages. *)
109134}
110135111111-val id : t -> Proto_id.t
112112-val identity_id : t -> Proto_id.t
113113-val email_id : t -> Proto_id.t
114114-val thread_id : t -> Proto_id.t
136136+val id : t -> Proto_id.t option
137137+val identity_id : t -> Proto_id.t option
138138+val email_id : t -> Proto_id.t option
139139+val thread_id : t -> Proto_id.t option
115140val envelope : t -> Envelope.t option
116116-val send_at : t -> Ptime.t
117117-val undo_status : t -> undo_status
141141+val send_at : t -> Ptime.t option
142142+val undo_status : t -> undo_status option
118143val delivery_status : t -> (string * Delivery_status.t) list option
119119-val dsn_blob_ids : t -> Proto_id.t list
120120-val mdn_blob_ids : t -> Proto_id.t list
144144+val dsn_blob_ids : t -> Proto_id.t list option
145145+val mdn_blob_ids : t -> Proto_id.t list option
121146122147val jsont : t Jsont.t
123148
+23-4
lib/mail/mail_thread.ml
···33 SPDX-License-Identifier: ISC
44 ---------------------------------------------------------------------------*)
5566+(* Thread properties *)
77+88+type property = [
99+ | `Id
1010+ | `Email_ids
1111+]
1212+1313+let property_to_string : [< property ] -> string = function
1414+ | `Id -> "id"
1515+ | `Email_ids -> "emailIds"
1616+1717+let property_of_string s : property option =
1818+ match s with
1919+ | "id" -> Some `Id
2020+ | "emailIds" -> Some `Email_ids
2121+ | _ -> None
2222+2323+(* Thread type *)
2424+625type t = {
77- id : Proto_id.t;
88- email_ids : Proto_id.t list;
2626+ id : Proto_id.t option;
2727+ email_ids : Proto_id.t list option;
928}
10291130let id t = t.id
···1635let jsont =
1736 let kind = "Thread" in
1837 Jsont.Object.map ~kind make
1919- |> Jsont.Object.mem "id" Proto_id.jsont ~enc:id
2020- |> Jsont.Object.mem "emailIds" (Jsont.list Proto_id.jsont) ~enc:email_ids
3838+ |> Jsont.Object.opt_mem "id" Proto_id.jsont ~enc:id
3939+ |> Jsont.Object.opt_mem "emailIds" (Jsont.list Proto_id.jsont) ~enc:email_ids
2140 |> Jsont.Object.finish
+24-5
lib/mail/mail_thread.mli
···33 SPDX-License-Identifier: ISC
44 ---------------------------------------------------------------------------*)
5566-(** Thread type as defined in RFC 8621 Section 3
66+(** Thread type as defined in RFC 8621 Section 3
7788 @canonical Jmap.Proto.Thread *)
991010+(** {1 Thread Properties}
1111+1212+ Polymorphic variants for type-safe property selection in Thread/get requests.
1313+ Threads have only two properties per RFC 8621 Section 3. *)
1414+1515+(** All Thread properties that can be requested. *)
1616+type property = [
1717+ | `Id
1818+ | `Email_ids
1919+]
2020+2121+val property_to_string : [< property ] -> string
2222+(** Convert a property to its wire name (e.g., [`Email_ids] -> "emailIds"). *)
2323+2424+val property_of_string : string -> property option
2525+(** Parse a property name, case-sensitive. *)
2626+2727+(** {1 Thread Object} *)
2828+1029type t = {
1111- id : Proto_id.t;
3030+ id : Proto_id.t option;
1231 (** Server-assigned thread id. *)
1313- email_ids : Proto_id.t list;
3232+ email_ids : Proto_id.t list option;
1433 (** Ids of emails in this thread, in date order. *)
1534}
16351717-val id : t -> Proto_id.t
1818-val email_ids : t -> Proto_id.t list
3636+val id : t -> Proto_id.t option
3737+val email_ids : t -> Proto_id.t list option
19382039val jsont : t Jsont.t
···11+(* Toplevel printers for JMAP types
22+33+ Usage in toplevel:
44+ #require "jmap.top";;
55+66+ Printers are automatically installed when the library is loaded.
77+*)
88+99+(* JSON printers *)
1010+1111+let json_printer ppf (json : Jsont.json) =
1212+ match Jsont_bytesrw.encode_string Jsont.json json with
1313+ | Ok s -> Format.pp_print_string ppf s
1414+ | Error e -> Format.fprintf ppf "<json encoding error: %s>" e
1515+1616+let jsont_error_printer ppf (e : Jsont.Error.t) =
1717+ Format.pp_print_string ppf (Jsont.Error.to_string e)
1818+1919+(* JSON encoding helpers *)
2020+2121+let encode (type a) (codec : a Jsont.t) (value : a) : Jsont.json =
2222+ match Jsont.Json.encode codec value with
2323+ | Ok json -> json
2424+ | Error e -> invalid_arg e
2525+2626+let encode_string (type a) (codec : a Jsont.t) (value : a) : string =
2727+ match Jsont_bytesrw.encode_string codec value with
2828+ | Ok s -> s
2929+ | Error e -> invalid_arg e
3030+3131+let pp_as_json (type a) (codec : a Jsont.t) ppf (value : a) =
3232+ json_printer ppf (encode codec value)
3333+3434+(* Automatic printer installation *)
3535+3636+let printers =
3737+ [ "Jmap.Id.pp";
3838+ "Jmap.Keyword.pp";
3939+ "Jmap.Role.pp";
4040+ "Jmap.Capability.pp";
4141+ "Jmap.Error.pp";
4242+ "Jmap_top.json_printer";
4343+ "Jmap_top.jsont_error_printer" ]
4444+4545+(* Suppress stderr during printer installation to avoid noise in MDX tests *)
4646+let null_formatter = Format.make_formatter (fun _ _ _ -> ()) (fun () -> ())
4747+4848+let eval_string_quiet str =
4949+ try
5050+ let lexbuf = Lexing.from_string str in
5151+ let phrase = !Toploop.parse_toplevel_phrase lexbuf in
5252+ Toploop.execute_phrase false null_formatter phrase
5353+ with _ -> false
5454+5555+let rec do_install_printers = function
5656+ | [] -> true
5757+ | printer :: rest ->
5858+ let cmd = Printf.sprintf "#install_printer %s;;" printer in
5959+ eval_string_quiet cmd && do_install_printers rest
6060+6161+let install () =
6262+ (* Silently ignore failures - this handles non-toplevel contexts like MDX *)
6363+ ignore (do_install_printers printers)
6464+6565+(* Only auto-install when OCAML_TOPLEVEL_NAME is set, indicating a real toplevel *)
6666+let () =
6767+ if Sys.getenv_opt "OCAML_TOPLEVEL_NAME" <> None then
6868+ install ()
+50
lib/top/jmap_top.mli
···11+(** Toplevel printers for JMAP types.
22+33+ Printers are automatically installed when the library is loaded:
44+ {[
55+ #require "jmap.top";;
66+ ]}
77+88+ After loading, JMAP types will display nicely:
99+ {[
1010+ # Jmap.Id.of_string_exn "abc123";;
1111+ - : Jmap.Id.t = <id:abc123>
1212+1313+ # Jmap.Keyword.of_string "$seen";;
1414+ - : Jmap.Keyword.t = `Seen
1515+1616+ # Jmap.Role.of_string "inbox";;
1717+ - : Jmap.Role.t = `Inbox
1818+ ]}
1919+2020+ JSON values display as formatted strings, making it easy to see
2121+ how OCaml types map to JMAP JSON. *)
2222+2323+(** {1 JSON Printers} *)
2424+2525+val json_printer : Format.formatter -> Jsont.json -> unit
2626+(** Formats a JSON value as a compact JSON string. *)
2727+2828+val jsont_error_printer : Format.formatter -> Jsont.Error.t -> unit
2929+(** Formats a Jsont parsing error. *)
3030+3131+(** {1 JSON Encoding Helpers}
3232+3333+ These functions encode OCaml types to JSON, useful for understanding
3434+ how the library maps to JMAP wire format. *)
3535+3636+val encode : 'a Jsont.t -> 'a -> Jsont.json
3737+(** [encode codec value] encodes a value to JSON using the given codec.
3838+ Raises [Invalid_argument] on encoding failure. *)
3939+4040+val encode_string : 'a Jsont.t -> 'a -> string
4141+(** [encode_string codec value] encodes a value to a JSON string. *)
4242+4343+val pp_as_json : 'a Jsont.t -> Format.formatter -> 'a -> unit
4444+(** [pp_as_json codec ppf value] pretty-prints a value as JSON. *)
4545+4646+(** {1 Installation} *)
4747+4848+val install : unit -> unit
4949+(** [install ()] installs all printers. This is called automatically when
5050+ the library is loaded, but can be called again if needed. *)
+24-18
test/proto/test_proto.ml
···4848 Alcotest.failf "%s: re-decode failed: %s" name (Jsont.Error.to_string e)
4949 | Ok _ -> ()
50505151+(* Helpers for extracting values from optional fields in tests *)
5252+let get_id opt = match opt with Some id -> Jmap.Proto.Id.to_string id | None -> Alcotest.fail "expected id"
5353+let get_string opt = match opt with Some s -> s | None -> Alcotest.fail "expected string"
5454+let get_int64 opt = match opt with Some n -> n | None -> Alcotest.fail "expected int64"
5555+let get_bool opt = match opt with Some b -> b | None -> Alcotest.fail "expected bool"
5656+5157(* ID tests *)
5258module Id_tests = struct
5359 open Jmap.Proto
···607613 match decode Mailbox.jsont json with
608614 | Error e -> Alcotest.failf "decode failed: %s" (Jsont.Error.to_string e)
609615 | Ok mb ->
610610- Alcotest.(check string) "id" "mb1" (Jmap.Proto.Id.to_string (Mailbox.id mb));
611611- Alcotest.(check string) "name" "Inbox" (Mailbox.name mb);
616616+ Alcotest.(check string) "id" "mb1" (get_id (Mailbox.id mb));
617617+ Alcotest.(check string) "name" "Inbox" (get_string (Mailbox.name mb));
612618 Alcotest.(check (option role_testable)) "role" (Some `Inbox) (Mailbox.role mb);
613613- Alcotest.(check int64) "totalEmails" 150L (Mailbox.total_emails mb);
614614- Alcotest.(check int64) "unreadEmails" 5L (Mailbox.unread_emails mb)
619619+ Alcotest.(check int64) "totalEmails" 150L (get_int64 (Mailbox.total_emails mb));
620620+ Alcotest.(check int64) "unreadEmails" 5L (get_int64 (Mailbox.unread_emails mb))
615621616622 let test_roundtrip () =
617623 test_roundtrip "simple roundtrip" Mailbox.jsont "mail/mailbox/valid/simple.json" ()
···628634 | Error e -> Alcotest.failf "decode failed: %s" (Jsont.Error.to_string e)
629635 | Ok mb ->
630636 Alcotest.(check (option role_testable)) "role" (Some `Archive) (Mailbox.role mb);
631631- Alcotest.(check int64) "totalEmails" 1000L (Mailbox.total_emails mb)
637637+ Alcotest.(check int64) "totalEmails" 1000L (get_int64 (Mailbox.total_emails mb))
632638633639 let tests = [
634640 "valid: simple", `Quick, test_simple;
···659665 match decode Email.jsont json with
660666 | Error e -> Alcotest.failf "decode failed: %s" (Jsont.Error.to_string e)
661667 | Ok email ->
662662- Alcotest.(check string) "id" "e1" (Jmap.Proto.Id.to_string (Email.id email));
663663- Alcotest.(check string) "blobId" "blob1" (Jmap.Proto.Id.to_string (Email.blob_id email));
664664- Alcotest.(check int64) "size" 1024L (Email.size email)
668668+ Alcotest.(check string) "id" "e1" (get_id (Email.id email));
669669+ Alcotest.(check string) "blobId" "blob1" (get_id (Email.blob_id email));
670670+ Alcotest.(check int64) "size" 1024L (get_int64 (Email.size email))
665671666672 let test_full_values () =
667673 let json = read_file "mail/email/valid/full.json" in
···669675 | Error e -> Alcotest.failf "decode failed: %s" (Jsont.Error.to_string e)
670676 | Ok email ->
671677 Alcotest.(check (option string)) "subject" (Some "Re: Important meeting") (Email.subject email);
672672- Alcotest.(check bool) "hasAttachment" true (Email.has_attachment email);
678678+ Alcotest.(check bool) "hasAttachment" true (get_bool (Email.has_attachment email));
673679 (* Check from address *)
674680 match Email.from email with
675681 | None -> Alcotest.fail "expected from address"
···702708 match decode Email.jsont json with
703709 | Error e -> Alcotest.failf "decode failed: %s" (Jsont.Error.to_string e)
704710 | Ok email ->
705705- let keywords = Email.keywords email in
711711+ let keywords = Option.value ~default:[] (Email.keywords email) in
706712 Alcotest.(check int) "keywords count" 3 (List.length keywords);
707713 Alcotest.(check bool) "$seen present" true (List.mem_assoc "$seen" keywords);
708714 Alcotest.(check bool) "$flagged present" true (List.mem_assoc "$flagged" keywords)
···712718 match decode Email.jsont json with
713719 | Error e -> Alcotest.failf "decode failed: %s" (Jsont.Error.to_string e)
714720 | Ok email ->
715715- let mailbox_ids = Email.mailbox_ids email in
721721+ let mailbox_ids = Option.value ~default:[] (Email.mailbox_ids email) in
716722 Alcotest.(check int) "mailboxIds count" 3 (List.length mailbox_ids)
717723718724 let tests = [
···747753 match decode Thread.jsont json with
748754 | Error e -> Alcotest.failf "decode failed: %s" (Jsont.Error.to_string e)
749755 | Ok thread ->
750750- Alcotest.(check string) "id" "t2" (Jmap.Proto.Id.to_string (Thread.id thread));
751751- Alcotest.(check int) "emailIds count" 5 (List.length (Thread.email_ids thread))
756756+ Alcotest.(check string) "id" "t2" (get_id (Thread.id thread));
757757+ Alcotest.(check int) "emailIds count" 5 (List.length (Option.value ~default:[] (Thread.email_ids thread)))
752758753759 let tests = [
754760 "valid: simple", `Quick, test_simple;
···769775 match decode Identity.jsont json with
770776 | Error e -> Alcotest.failf "decode failed: %s" (Jsont.Error.to_string e)
771777 | Ok ident ->
772772- Alcotest.(check string) "name" "Work Identity" (Identity.name ident);
773773- Alcotest.(check string) "email" "john.doe@company.com" (Identity.email ident);
774774- Alcotest.(check bool) "mayDelete" true (Identity.may_delete ident)
778778+ Alcotest.(check string) "name" "Work Identity" (get_string (Identity.name ident));
779779+ Alcotest.(check string) "email" "john.doe@company.com" (get_string (Identity.email ident));
780780+ Alcotest.(check bool) "mayDelete" true (get_bool (Identity.may_delete ident))
775781776782 let tests = [
777783 "valid: simple", `Quick, test_simple;
···948954 match decode Submission.jsont json with
949955 | Error e -> Alcotest.failf "decode failed: %s" (Jsont.Error.to_string e)
950956 | Ok sub ->
951951- Alcotest.(check string) "id" "sub1" (Jmap.Proto.Id.to_string (Submission.id sub));
957957+ Alcotest.(check string) "id" "sub1" (get_id (Submission.id sub));
952958 (* Check undoStatus is Pending *)
953959 match Submission.undo_status sub with
954954- | `Pending -> ()
960960+ | Some `Pending -> ()
955961 | _ -> Alcotest.fail "expected undoStatus to be pending"
956962957963 let tests = [