(*---------------------------------------------------------------------------
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