{0 JMAP Tutorial} This tutorial introduces JMAP (JSON Meta Application Protocol) and demonstrates the [jmap] OCaml library through interactive examples. JMAP is defined in {{:https://www.rfc-editor.org/rfc/rfc8620}RFC 8620} (core) and {{:https://www.rfc-editor.org/rfc/rfc8621}RFC 8621} (mail). {1 What is JMAP?} JMAP is a modern, efficient protocol for synchronizing mail and other data. It's designed as a better alternative to IMAP, addressing many of IMAP's limitations: {ul {- {b Stateless over HTTP}: Unlike IMAP's persistent TCP connections, JMAP uses standard HTTP POST requests with JSON payloads.} {- {b Efficient batching}: Multiple operations can be combined into a single request, reducing round-trips.} {- {b Result references}: The output of one method call can be used as input to another in the same request.} {- {b Push support}: Built-in mechanisms for real-time notifications.} {- {b Binary data handling}: Separate upload/download endpoints for large attachments.}} The core protocol (RFC 8620) defines the general structure, while RFC 8621 extends it specifically for email, mailboxes, threads, and related objects. {1 Setup} First, let's set up our environment. In the toplevel, load the library with [#require "jmap.top";;] which will automatically install pretty printers. {@ocaml[ # Jmap_top.install ();; - : unit = () # open Jmap;; ]} For parsing and encoding JSON, we'll use some helper functions: {@ocaml[ # let parse_json s = match Jsont_bytesrw.decode_string Jsont.json s with | Ok json -> json | Error e -> failwith e;; val parse_json : string -> Jsont.json = # let json_to_string json = match Jsont_bytesrw.encode_string ~format:Jsont.Indent Jsont.json json with | Ok s -> s | Error e -> failwith e;; val json_to_string : Jsont.json -> string = ]} {1 JMAP Identifiers} From {{:https://www.rfc-editor.org/rfc/rfc8620#section-1.2}RFC 8620 Section 1.2}: {i An "Id" is a String of at least 1 and a maximum of 255 octets in size, and it MUST only contain characters from the "URL and Filename Safe" base64 alphabet.} The {!Jmap.Id} module provides type-safe identifiers: {@ocaml[ # let id = Id.of_string_exn "abc123";; val id : Id.t = abc123 # Id.to_string id;; - : string = "abc123" ]} Invalid identifiers are rejected: {@ocaml[ # Id.of_string "";; - : (Id.t, string) result = Error "Id cannot be empty" # Id.of_string (String.make 256 'x');; - : (Id.t, string) result = Error "Id cannot exceed 255 characters" ]} {1 Keywords} Email keywords are string flags that indicate message state. RFC 8621 defines standard keywords, and the library represents them as polymorphic variants for type safety. {2 Standard Keywords} From {{:https://www.rfc-editor.org/rfc/rfc8621#section-4.1.1}RFC 8621 Section 4.1.1}: {@ocaml[ # Keyword.of_string "$seen";; - : Keyword.t = $seen # Keyword.of_string "$flagged";; - : Keyword.t = $flagged # Keyword.of_string "$draft";; - : Keyword.t = $draft # Keyword.of_string "$answered";; - : Keyword.t = $answered ]} The standard keywords are: {ul {- [`Seen] - The email has been read} {- [`Flagged] - The email has been flagged for attention} {- [`Draft] - The email is a draft being composed} {- [`Answered] - The email has been replied to} {- [`Forwarded] - The email has been forwarded} {- [`Phishing] - The email is likely phishing} {- [`Junk] - The email is spam} {- [`NotJunk] - The email is definitely not spam}} {2 Extended Keywords} The library also supports draft-ietf-mailmaint extended keywords: {@ocaml[ # Keyword.of_string "$notify";; - : Keyword.t = $notify # Keyword.of_string "$muted";; - : Keyword.t = $muted # Keyword.of_string "$hasattachment";; - : Keyword.t = $hasattachment ]} {2 Custom Keywords} Unknown keywords are preserved as [`Custom]: {@ocaml[ # Keyword.of_string "$my_custom_flag";; - : Keyword.t = $my_custom_flag ]} {2 Converting Back to Strings} {@ocaml[ # Keyword.to_string `Seen;; - : string = "$seen" # Keyword.to_string `Flagged;; - : string = "$flagged" # Keyword.to_string (`Custom "$important");; - : string = "$important" ]} {1 Mailbox Roles} Mailboxes can have special roles that indicate their purpose. From {{:https://www.rfc-editor.org/rfc/rfc8621#section-2}RFC 8621 Section 2}: {@ocaml[ # Role.of_string "inbox";; - : Role.t = inbox # Role.of_string "sent";; - : Role.t = sent # Role.of_string "drafts";; - : Role.t = drafts # Role.of_string "trash";; - : Role.t = trash # Role.of_string "junk";; - : Role.t = junk # Role.of_string "archive";; - : Role.t = archive ]} Custom roles are also supported: {@ocaml[ # Role.of_string "receipts";; - : Role.t = receipts ]} {1 Capabilities} JMAP uses capability URIs to indicate supported features. From {{:https://www.rfc-editor.org/rfc/rfc8620#section-2}RFC 8620 Section 2}: {@ocaml[ # Capability.core_uri;; - : string = "urn:ietf:params:jmap:core" # Capability.mail_uri;; - : string = "urn:ietf:params:jmap:mail" # Capability.submission_uri;; - : string = "urn:ietf:params:jmap:submission" ]} {@ocaml[ # Capability.of_string Capability.core_uri;; - : Capability.t = urn:ietf:params:jmap:core # Capability.of_string Capability.mail_uri;; - : Capability.t = urn:ietf:params:jmap:mail # Capability.of_string "urn:example:custom";; - : Capability.t = urn:example:custom ]} {1 Understanding JMAP JSON Structure} One of the key benefits of JMAP over IMAP is its use of JSON. Let's see how OCaml types map to the wire format. {2 Requests} A JMAP request contains: - [using]: List of capability URIs required - [methodCalls]: Array of method invocations Each method invocation is a triple: [methodName], [arguments], [callId]. Here's how a simple request is structured: {x@ocaml[ # let req = Jmap.Proto.Request.create ~using:[Capability.core_uri; Capability.mail_uri] ~method_calls:[ Jmap.Proto.Invocation.create ~name:"Mailbox/get" ~arguments:(parse_json {|{"accountId": "abc123"}|}) ~call_id:"c0" ] ();; Line 7, characters 18-22: Error: The function applied to this argument has type method_call_id:string -> Proto.Invocation.t This argument cannot be applied with label ~call_id # Jmap_top.encode Jmap.Proto.Request.jsont req |> json_to_string |> print_endline;; Line 1, characters 42-45: Error: Unbound value req Hint: Did you mean ref? ]x} {2 Email Filter Conditions} Filters demonstrate how complex query conditions map to JSON. From {{:https://www.rfc-editor.org/rfc/rfc8621#section-4.4.1}RFC 8621 Section 4.4.1}: {x@ocaml[ # let filter_condition : Jmap.Proto.Email.Filter_condition.t = { in_mailbox = Some (Id.of_string_exn "inbox123"); in_mailbox_other_than = None; before = None; after = None; min_size = None; max_size = None; all_in_thread_have_keyword = None; some_in_thread_have_keyword = None; none_in_thread_have_keyword = None; has_keyword = Some "$flagged"; not_keyword = None; has_attachment = Some true; text = None; from = Some "alice@"; to_ = None; cc = None; bcc = None; subject = Some "urgent"; body = None; header = None; };; Line 2, characters 23-52: Error: This expression has type Id.t but an expression was expected of type Proto.Id.t # Jmap_top.encode Jmap.Proto.Email.Filter_condition.jsont filter_condition |> json_to_string |> print_endline;; Line 1, characters 57-73: Error: Unbound value filter_condition ]x} Notice how: - OCaml record fields use [snake_case], but JSON uses [camelCase] - [None] values are omitted from JSON (not sent as [null]) - The filter only includes non-empty conditions {2 Filter Operators} Filters can be combined with AND, OR, and NOT operators: {x@ocaml[ # let combined_filter = Jmap.Proto.Filter.Operator { operator = `And; conditions = [ Condition filter_condition; Condition { filter_condition with has_keyword = Some "$seen" } ] };; Line 4, characters 17-33: Error: Unbound value filter_condition ]x} {1 Method Chaining} One of JMAP's most powerful features is result references - using the output of one method as input to another. The {!Jmap.Chain} module provides a monadic interface for building such requests. From {{:https://www.rfc-editor.org/rfc/rfc8620#section-3.7}RFC 8620 Section 3.7}: {i A method argument may use the result of a previous method invocation in the same request.} {2 Basic Example} Query for emails, then fetch their details: {[ open Jmap.Chain let request, handle = build ~capabilities:[core; mail] begin let* query = email_query ~account_id ~filter:(Condition { in_mailbox = Some inbox_id; (* ... *) }) ~limit:50L () in let* emails = email_get ~account_id ~ids:(from_query query) (* Reference query results! *) ~properties:["subject"; "from"; "receivedAt"] () in return emails end ][ {err@mdx-error[ Line 3, characters 46-50: Error: Unbound value core ]err}]} The key insight is [from_query query] - this creates a reference to the [ids] array from the query response. The server processes both calls in sequence, substituting the reference with actual IDs. {2 Creation and Submission} Create a draft and send it in one request: {[ let* set_h, draft_cid = email_set ~account_id ~create:[("draft1", draft_email_json)] () in let* _ = email_submission_set ~account_id ~create:[("sub1", submission_json ~email_id:(created_id_of_string "draft1") (* Reference creation! *) ~identity_id)] () in return set_h ][ {err@mdx-error[ Line 1, characters 1-5: Error: Unbound value ( let* ) ]err}]} {2 The RFC 8620 Example} The RFC provides a complex example: fetch from/date/subject for all emails in the first 10 threads in the inbox: {[ let* q = email_query ~account_id ~filter:(Condition { in_mailbox = Some inbox_id; (* ... *) }) ~sort:[comparator ~is_ascending:false "receivedAt"] ~collapse_threads:true ~limit:10L () in let* e1 = email_get ~account_id ~ids:(from_query q) ~properties:["threadId"] () in let* threads = thread_get ~account_id ~ids:(from_get_field e1 "threadId") (* Get threadIds from emails *) () in let* e2 = email_get ~account_id ~ids:(from_get_field threads "emailIds") (* Get all emailIds in threads *) ~properties:["from"; "receivedAt"; "subject"] () in return e2 ][ {err@mdx-error[ Line 1, characters 1-5: Error: Unbound value ( let* ) ]err}]} This entire flow executes in a {e single HTTP request}! {1 Error Handling} JMAP has a structured error system with three levels: {2 Request-Level Errors} These are returned with HTTP error status codes and RFC 7807 Problem Details. From {{:https://www.rfc-editor.org/rfc/rfc8620#section-3.6.1}RFC 8620 Section 3.6.1}: {@ocaml[ # Error.to_string (`Request { Error.type_ = "urn:ietf:params:jmap:error:unknownCapability"; status = Some 400; title = Some "Unknown Capability"; detail = Some "The server does not support 'urn:example:unsupported'"; limit = None; });; - : string = "Request error: urn:ietf:params:jmap:error:unknownCapability (status 400): The server does not support 'urn:example:unsupported'" ]} {2 Method-Level Errors} Individual method calls can fail while others succeed: {@ocaml[ # Error.to_string (`Method { Error.type_ = "invalidArguments"; description = Some "The 'filter' argument is malformed"; });; - : string = "Method error: invalidArguments: The 'filter' argument is malformed" ]} {2 SetError} Object-level errors in /set responses: {@ocaml[ # Error.to_string (`Set ("draft1", { Error.type_ = "invalidProperties"; description = Some "Unknown property: foobar"; properties = Some ["foobar"]; }));; - : string = "Set error for draft1: invalidProperties: Unknown property: foobar" ]} {1 Using with FastMail} FastMail is a popular JMAP provider. Here's how to connect: {[ (* Get a token from https://app.fastmail.com/settings/tokens *) let token = "your-api-token" (* The session URL for FastMail *) let session_url = "https://api.fastmail.com/jmap/session" (* For browser applications using jmap-brr: *) let main () = let open Fut.Syntax in let* conn = Jmap_brr.get_session ~url:(Jstr.v session_url) ~token:(Jstr.v token) in match conn with | Error e -> Brr.Console.(error [str "Error:"; e]); Fut.return () | Ok conn -> let session = Jmap_brr.session conn in Brr.Console.(log [str "Connected as:"; str (Jmap.Session.username session)]); Fut.return () ][ {err@mdx-error[ Line 9, characters 14-17: Error: Unbound module Fut Hint: Did you mean Fun? ]err}]} {1 Summary} JMAP (RFC 8620/8621) provides a modern, efficient protocol for email: {ol {- {b Sessions}: Discover capabilities and account information via GET request} {- {b Batching}: Combine multiple method calls in one request} {- {b References}: Use results from one method as input to another} {- {b Type Safety}: The [jmap] library uses polymorphic variants for keywords and roles} {- {b JSON Mapping}: OCaml types map cleanly to JMAP JSON structure} {- {b Browser Support}: The [jmap-brr] package enables browser-based clients}} The [jmap] library provides: {ul {- {!Jmap} - High-level interface with abstract types} {- {!Jmap.Proto} - Low-level protocol types matching the RFCs} {- {!Jmap.Chain} - Monadic interface for request chaining} {- [Jmap_brr] - Browser support via Brr/js_of_ocaml (separate package)}} {2 Key RFC References} {ul {- {{:https://www.rfc-editor.org/rfc/rfc8620}RFC 8620}: JMAP Core} {- {{:https://www.rfc-editor.org/rfc/rfc8621}RFC 8621}: JMAP for Mail} {- {{:https://www.rfc-editor.org/rfc/rfc7807}RFC 7807}: Problem Details for HTTP APIs}}