(*--------------------------------------------------------------------------- Copyright (c) 2025 Anil Madhavapeddy. All rights reserved. SPDX-License-Identifier: ISC ---------------------------------------------------------------------------*) (** Unified JMAP interface for OCaml This module provides a clean, ergonomic API for working with JMAP ({{:https://datatracker.ietf.org/doc/html/rfc8620}RFC 8620} / {{:https://datatracker.ietf.org/doc/html/rfc8621}RFC 8621}), combining the protocol and mail layers with abstract types and polymorphic variants. {2 JMAP vs IMAP} JMAP is a modern replacement for {{!/imap/Imap}IMAP} designed for efficient mobile and web clients: {@mermaid[ flowchart TB subgraph IMAP["IMAP (Persistent TCP)"] direction TB I1[LOGIN] --> I2[SELECT INBOX] I2 --> I3[FETCH 1:10] I3 --> I4[FETCH 11:20] I4 --> I5[...] style I1 fill:#f9f,stroke:#333 style I2 fill:#f9f,stroke:#333 style I3 fill:#f9f,stroke:#333 style I4 fill:#f9f,stroke:#333 end subgraph JMAP["JMAP (Stateless HTTP)"] direction TB J1["POST /api
3 method calls"] --> J2["Response
3 results"] style J1 fill:#9f9,stroke:#333 style J2 fill:#9f9,stroke:#333 end ]} {b Key differences:} - {b Stateless}: Each request is independent; no persistent connection - {b Batching}: Multiple operations in one HTTP request - {b Back-references}: Later calls can use results from earlier calls - {b JSON}: Human-readable wire format (not binary like IMAP) - {b Push}: Built-in event source for real-time notifications {2 Session Discovery} Before making API calls, clients must discover server capabilities by fetching the session resource from a well-known URL: {@mermaid[ sequenceDiagram participant App participant Server App->>Server: GET /.well-known/jmap Server-->>App: Session JSON Note over App: Extract apiUrl, accountId,
capabilities from session App->>Server: POST {apiUrl}
using: [capabilities]
methodCalls: [...] Server-->>App: methodResponses: [...] ]} The {!Session} contains the API endpoint URL, available accounts, and supported capabilities. Always check {!Session.has_capability} before using optional features. {2 Request Batching} Unlike IMAP's command-response model, JMAP sends all operations in a single HTTP request. Results from earlier calls can be referenced by later calls in the same batch using {b back-references}: {@mermaid[ sequenceDiagram participant App participant Client participant Server App->>Client: Chain.create () App->>Client: |> mailbox_query (find inbox) App->>Client: |> email_query ~in_mailbox:#0 App->>Client: |> email_get ~ids:#1 App->>Client: |> build Client->>Server: Single HTTP POST with 3 method calls Note over Server: #0 = mailbox_query result
#1 = email_query result Server-->>Client: All results in one response Client-->>App: inbox_id, email_ids, emails ]} The {!Chain} module provides a monadic interface for building these batched requests with automatic call ID generation and type-safe back-references. {2 State Synchronization} JMAP uses {b state strings} for efficient incremental sync. Each object type (Email, Mailbox, Thread) has a state that changes when objects are modified: {@mermaid[ flowchart LR subgraph "Initial Sync" A["Foo/get"] --> B["state: 'abc123'
list: [all objects]"] end subgraph "Incremental Sync" C["Foo/changes
sinceState: 'abc123'"] --> D["newState: 'def456'
created: [...]
updated: [...]
destroyed: [...]"] end B --> C ]} Store the state string from each response. On subsequent syncs, use [Foo/changes] with [sinceState] to get only what changed - far more efficient than re-fetching everything. {2 Partial Property Requests} @admonition.note All {!Email}, {!Mailbox}, and {!Thread} accessors return [option] types because JMAP responses only include properties you explicitly request. Unlike IMAP where FETCH returns all requested data, JMAP lets you specify exactly which properties you need: {[ (* Only fetch subject and from - other fields will be None *) email_get ~account_id ~properties:["subject"; "from"] () ]} This reduces bandwidth and server load. Always request only the properties you need. {2 Quick Start} {[ open Jmap (* Keywords use polymorphic variants *) let is_unread email = not (List.mem `Seen (Email.keywords email)) (* Mailbox roles are also polymorphic *) let find_inbox mailboxes = List.find_opt (fun m -> Mailbox.role m = Some `Inbox) mailboxes (* Build a batched request using Chain *) let fetch_inbox_emails ~account_id ~inbox_id = let open Chain in let* query = email_query ~account_id ~filter:(Condition (Email_filter.make ~in_mailbox:inbox_id ())) ~limit:50L () in let* emails = email_get ~account_id ~ids:(from_query query) ~properties:["id"; "subject"; "from"; "receivedAt"; "keywords"] () in return emails ]} {2 Module Structure} {b Core Types:} - {!Id} - JMAP identifiers (validated strings) - {!Keyword} - Email keywords as polymorphic variants ([`Seen], [`Flagged], ...) - {!Role} - Mailbox roles ([`Inbox], [`Sent], [`Drafts], ...) - {!module-Error} - Unified error type for all JMAP operations {b Data Types:} - {!Session} - Server capabilities and account information - {!Email}, {!Mailbox}, {!Thread} - Mail objects with accessor functions - {!Email_filter}, {!Mailbox_filter} - Query filter builders {b Request Building:} - {!Chain} - Monadic interface for batched requests with back-references - {!Proto} - Low-level protocol types (rarely needed directly) {2 References} - {{:https://datatracker.ietf.org/doc/html/rfc8620}RFC 8620} - JMAP Core - {{:https://datatracker.ietf.org/doc/html/rfc8621}RFC 8621} - JMAP for Mail - {{:https://jmap.io}jmap.io} - Official JMAP website with guides *) (** {1 Protocol Layer Re-exports} *) (** Low-level JMAP protocol types (RFC 8620/8621). These are the raw protocol and mail types. For most use cases, prefer the higher-level types in this module. *) module Proto = Jmap_proto (** {1 Core Types} *) (** Unified error type for JMAP operations. *) module Error : sig (** Request-level error (RFC 7807 Problem Details). *) type request = { type_ : string; status : int option; title : string option; detail : string option; limit : string option; } (** Method-level error. *) type method_ = { type_ : string; description : string option; } (** Set operation error for a specific object. *) type set = { type_ : string; description : string option; properties : string list option; } (** Unified error type. All errors from JSON parsing, HTTP, session management, and JMAP method calls are represented as polymorphic variants. *) type t = [ | `Request of request | `Method of method_ | `Set of string * set | `Json of string | `Http of int * string | `Connection of string | `Session of string ] val pp : Format.formatter -> t -> unit val to_string : t -> string end (** JMAP identifier type. *) module Id : sig type t val of_string : string -> (t, string) result val of_string_exn : string -> t val to_string : t -> string val compare : t -> t -> int val equal : t -> t -> bool val pp : Format.formatter -> t -> unit end (** Email keyword type. Standard keywords are represented as polymorphic variants. Custom keywords use [`Custom of string]. *) module Keyword : sig (** RFC 8621 standard keywords *) type standard = [ | `Seen | `Flagged | `Answered | `Draft | `Forwarded | `Phishing | `Junk | `NotJunk ] (** draft-ietf-mailmaint extended keywords *) type extended = [ | `Notify | `Muted | `Followed | `Memo | `HasMemo | `HasAttachment | `HasNoAttachment | `AutoSent | `Unsubscribed | `CanUnsubscribe | `Imported | `IsTrusted | `MaskedEmail | `New ] (** Apple Mail flag color keywords *) type flag_bits = [ | `MailFlagBit0 | `MailFlagBit1 | `MailFlagBit2 ] type t = [ | standard | extended | flag_bits | `Custom of string ] val of_string : string -> t val to_string : t -> string val pp : Format.formatter -> t -> unit (** Apple Mail flag colors *) type flag_color = [ | `Red | `Orange | `Yellow | `Green | `Blue | `Purple | `Gray ] val flag_color_of_keywords : t list -> flag_color option (** [flag_color_of_keywords keywords] extracts the flag color from a list of keywords. Returns [None] for invalid bit combinations. *) val flag_color_to_keywords : flag_color -> t list (** [flag_color_to_keywords color] returns the keywords to set for the color. *) end (** Mailbox role type. Standard roles are represented as polymorphic variants. Custom roles use [`Custom of string]. *) module Role : sig (** RFC 8621 standard roles *) type standard = [ | `Inbox | `Sent | `Drafts | `Trash | `Junk | `Archive | `Flagged | `Important | `All | `Subscribed ] (** draft-ietf-mailmaint extended roles *) type extended = [ | `Snoozed | `Scheduled | `Memos ] type t = [ | standard | extended | `Custom of string ] val of_string : string -> t val to_string : t -> string val pp : Format.formatter -> t -> unit end (** JMAP capability type. Standard capabilities are represented as polymorphic variants. Custom capabilities use [`Custom of string]. *) module Capability : sig type t = [ | `Core | `Mail | `Submission | `VacationResponse | `Custom of string ] val core_uri : string val mail_uri : string val submission_uri : string val vacation_uri : string val of_string : string -> t val to_string : t -> string val pp : Format.formatter -> t -> unit end (** {1 Session Types} *) (** JMAP session information. *) module Session : sig (** Account information. *) module Account : sig type t val name : t -> string val is_personal : t -> bool val is_read_only : t -> bool end type t val capabilities : t -> (string * Jsont.json) list val accounts : t -> (Id.t * Account.t) list val primary_accounts : t -> (string * Id.t) list val username : t -> string val api_url : t -> string val download_url : t -> string val upload_url : t -> string val event_source_url : t -> string val state : t -> string val get_account : Id.t -> t -> Account.t option val primary_account_for : string -> t -> Id.t option val has_capability : string -> t -> bool end (** {1 Mail Types} *) (** Email address with optional display name. *) module Email_address : sig type t val name : t -> string option val email : t -> string val create : ?name:string -> string -> t end (** Email mailbox. All accessors return option types since responses only include requested properties. *) module Mailbox : sig type t val id : t -> Id.t option val name : t -> string option val parent_id : t -> Id.t option val sort_order : t -> int64 option val total_emails : t -> int64 option val unread_emails : t -> int64 option val total_threads : t -> int64 option val unread_threads : t -> int64 option val is_subscribed : t -> bool option val role : t -> Role.t option (** Mailbox rights. *) module Rights : sig type t val may_read_items : t -> bool val may_add_items : t -> bool val may_remove_items : t -> bool val may_set_seen : t -> bool val may_set_keywords : t -> bool val may_create_child : t -> bool val may_rename : t -> bool val may_delete : t -> bool val may_submit : t -> bool end val my_rights : t -> Rights.t option end (** Email thread. All accessors return option types since responses only include requested properties. *) module Thread : sig type t val id : t -> Id.t option val email_ids : t -> Id.t list option end (** Email message. *) module Email : sig (** Email body part. *) module Body : sig type part type value val part_id : part -> string option val blob_id : part -> Id.t option val size : part -> int64 option val name : part -> string option val type_ : part -> string val charset : part -> string option val disposition : part -> string option val cid : part -> string option val language : part -> string list option val location : part -> string option val value_text : value -> string val value_is_truncated : value -> bool val value_is_encoding_problem : value -> bool end (** All accessors return option types since responses only include requested properties. *) type t val id : t -> Id.t option val blob_id : t -> Id.t option val thread_id : t -> Id.t option val mailbox_ids : t -> (Id.t * bool) list option val size : t -> int64 option val received_at : t -> Ptime.t option val message_id : t -> string list option val in_reply_to : t -> string list option val references : t -> string list option val subject : t -> string option val sent_at : t -> Ptime.t option val has_attachment : t -> bool option val preview : t -> string option (** Get active keywords as polymorphic variants. Returns empty list if keywords property was not requested. *) val keywords : t -> Keyword.t list (** Check if email has a specific keyword. Returns false if keywords property was not requested. *) val has_keyword : Keyword.t -> t -> bool val from : t -> Email_address.t list option val to_ : t -> Email_address.t list option val cc : t -> Email_address.t list option val bcc : t -> Email_address.t list option val reply_to : t -> Email_address.t list option val sender : t -> Email_address.t list option val text_body : t -> Body.part list option val html_body : t -> Body.part list option val attachments : t -> Body.part list option val body_values : t -> (string * Body.value) list option end (** Email identity for sending. All accessors return option types since responses only include requested properties. *) module Identity : sig type t val id : t -> Id.t option val name : t -> string option val email : t -> string option val reply_to : t -> Email_address.t list option val bcc : t -> Email_address.t list option val text_signature : t -> string option val html_signature : t -> string option val may_delete : t -> bool option end (** Email submission for outgoing mail. All accessors return option types since responses only include requested properties. *) module Submission : sig type t val id : t -> Id.t option val identity_id : t -> Id.t option val email_id : t -> Id.t option val thread_id : t -> Id.t option val send_at : t -> Ptime.t option val undo_status : t -> Proto.Submission.undo_status option val delivery_status : t -> (string * Proto.Submission.Delivery_status.t) list option val dsn_blob_ids : t -> Id.t list option val mdn_blob_ids : t -> Id.t list option end (** Vacation auto-response. *) module Vacation : sig type t val id : t -> Id.t val is_enabled : t -> bool val from_date : t -> Ptime.t option val to_date : t -> Ptime.t option val subject : t -> string option val text_body : t -> string option val html_body : t -> string option end (** Search snippet with highlighted matches. *) module Search_snippet : sig type t val email_id : t -> Id.t val subject : t -> string option val preview : t -> string option end (** {1 Filter Types} *) (** Email filter conditions for queries. *) module Email_filter : sig type condition (** Create an email filter condition. All parameters are optional. Omitted parameters are not included in the filter. Use [make ()] for an empty filter. *) val make : ?in_mailbox:Id.t -> ?in_mailbox_other_than:Id.t list -> ?before:Ptime.t -> ?after:Ptime.t -> ?min_size:int64 -> ?max_size:int64 -> ?all_in_thread_have_keyword:Keyword.t -> ?some_in_thread_have_keyword:Keyword.t -> ?none_in_thread_have_keyword:Keyword.t -> ?has_keyword:Keyword.t -> ?not_keyword:Keyword.t -> ?has_attachment:bool -> ?text:string -> ?from:string -> ?to_:string -> ?cc:string -> ?bcc:string -> ?subject:string -> ?body:string -> ?header:(string * string option) -> unit -> condition end (** Mailbox filter conditions for queries. *) module Mailbox_filter : sig type condition (** Create a mailbox filter condition. All parameters are optional. For [role]: [Some (Some r)] filters by role [r], [Some None] filters for mailboxes with no role, [None] doesn't filter by role. *) val make : ?parent_id:Id.t option -> ?name:string -> ?role:Role.t option -> ?has_any_role:bool -> ?is_subscribed:bool -> unit -> condition end (** {1 Response Types} *) (** Generic /get response wrapper. *) module Get_response : sig type 'a t val account_id : 'a t -> Id.t val state : 'a t -> string val list : 'a t -> 'a list val not_found : 'a t -> Id.t list end (** Query response. *) module Query_response : sig type t val account_id : t -> Id.t val query_state : t -> string val can_calculate_changes : t -> bool val position : t -> int64 val ids : t -> Id.t list val total : t -> int64 option end (** Changes response. *) module Changes_response : sig type t val account_id : t -> Id.t val old_state : t -> string val new_state : t -> string val has_more_changes : t -> bool val created : t -> Id.t list val updated : t -> Id.t list val destroyed : t -> Id.t list end (** {1 JSONABLE Interface} *) (** Module type for types that can be serialized to/from JSON bytes. *) module type JSONABLE = sig type t val of_string : string -> (t, Error.t) result val to_string : t -> (string, Error.t) result end (** {1 Request Chaining} *) (** JMAP method chaining with automatic result references. This module provides a monadic interface for building JMAP requests where method calls can reference results from previous calls. *) module Chain = Chain