forked from
anil.recoil.org/ocaml-jmap
this repo has no description
1(*---------------------------------------------------------------------------
2 Copyright (c) 2025 Anil Madhavapeddy. All rights reserved.
3 SPDX-License-Identifier: ISC
4 ---------------------------------------------------------------------------*)
5
6(** Unified JMAP interface for OCaml
7
8 This module provides a clean, ergonomic API for working with JMAP
9 ({{:https://datatracker.ietf.org/doc/html/rfc8620}RFC 8620} /
10 {{:https://datatracker.ietf.org/doc/html/rfc8621}RFC 8621}), combining the
11 protocol and mail layers with abstract types and polymorphic variants.
12
13 {2 JMAP vs IMAP}
14
15 JMAP is a modern replacement for {{!/imap/Imap}IMAP} designed for efficient
16 mobile and web clients:
17
18 {@mermaid[
19 flowchart TB
20 subgraph IMAP["IMAP (Persistent TCP)"]
21 direction TB
22 I1[LOGIN] --> I2[SELECT INBOX]
23 I2 --> I3[FETCH 1:10]
24 I3 --> I4[FETCH 11:20]
25 I4 --> I5[...]
26 style I1 fill:#f9f,stroke:#333
27 style I2 fill:#f9f,stroke:#333
28 style I3 fill:#f9f,stroke:#333
29 style I4 fill:#f9f,stroke:#333
30 end
31
32 subgraph JMAP["JMAP (Stateless HTTP)"]
33 direction TB
34 J1["POST /api<br/>3 method calls"] --> J2["Response<br/>3 results"]
35 style J1 fill:#9f9,stroke:#333
36 style J2 fill:#9f9,stroke:#333
37 end
38 ]}
39
40 {b Key differences:}
41 - {b Stateless}: Each request is independent; no persistent connection
42 - {b Batching}: Multiple operations in one HTTP request
43 - {b Back-references}: Later calls can use results from earlier calls
44 - {b JSON}: Human-readable wire format (not binary like IMAP)
45 - {b Push}: Built-in event source for real-time notifications
46
47 {2 Session Discovery}
48
49 Before making API calls, clients must discover server capabilities by
50 fetching the session resource from a well-known URL:
51
52 {@mermaid[
53 sequenceDiagram
54 participant App
55 participant Server
56
57 App->>Server: GET /.well-known/jmap
58 Server-->>App: Session JSON
59
60 Note over App: Extract apiUrl, accountId,<br/>capabilities from session
61
62 App->>Server: POST {apiUrl}<br/>using: [capabilities]<br/>methodCalls: [...]
63 Server-->>App: methodResponses: [...]
64 ]}
65
66 The {!Session} contains the API endpoint URL, available accounts, and
67 supported capabilities. Always check {!Session.has_capability} before
68 using optional features.
69
70 {2 Request Batching}
71
72 Unlike IMAP's command-response model, JMAP sends all operations in a
73 single HTTP request. Results from earlier calls can be referenced by
74 later calls in the same batch using {b back-references}:
75
76 {@mermaid[
77 sequenceDiagram
78 participant App
79 participant Client
80 participant Server
81
82 App->>Client: Chain.create ()
83 App->>Client: |> mailbox_query (find inbox)
84 App->>Client: |> email_query ~in_mailbox:#0
85 App->>Client: |> email_get ~ids:#1
86 App->>Client: |> build
87
88 Client->>Server: Single HTTP POST with 3 method calls
89
90 Note over Server: #0 = mailbox_query result<br/>#1 = email_query result
91
92 Server-->>Client: All results in one response
93 Client-->>App: inbox_id, email_ids, emails
94 ]}
95
96 The {!Chain} module provides a monadic interface for building these
97 batched requests with automatic call ID generation and type-safe
98 back-references.
99
100 {2 State Synchronization}
101
102 JMAP uses {b state strings} for efficient incremental sync. Each object
103 type (Email, Mailbox, Thread) has a state that changes when objects are
104 modified:
105
106 {@mermaid[
107 flowchart LR
108 subgraph "Initial Sync"
109 A["Foo/get"] --> B["state: 'abc123'<br/>list: [all objects]"]
110 end
111
112 subgraph "Incremental Sync"
113 C["Foo/changes<br/>sinceState: 'abc123'"] --> D["newState: 'def456'<br/>created: [...]<br/>updated: [...]<br/>destroyed: [...]"]
114 end
115
116 B --> C
117 ]}
118
119 Store the state string from each response. On subsequent syncs, use
120 [Foo/changes] with [sinceState] to get only what changed - far more
121 efficient than re-fetching everything.
122
123 {2 Partial Property Requests}
124
125 @admonition.note All {!Email}, {!Mailbox}, and {!Thread} accessors return
126 [option] types because JMAP responses only include properties you
127 explicitly request.
128
129 Unlike IMAP where FETCH returns all requested data, JMAP lets you
130 specify exactly which properties you need:
131
132 {[
133 (* Only fetch subject and from - other fields will be None *)
134 email_get ~account_id ~properties:["subject"; "from"] ()
135 ]}
136
137 This reduces bandwidth and server load. Always request only the
138 properties you need.
139
140 {2 Quick Start}
141
142 {[
143 open Jmap
144
145 (* Keywords use polymorphic variants *)
146 let is_unread email =
147 not (List.mem `Seen (Email.keywords email))
148
149 (* Mailbox roles are also polymorphic *)
150 let find_inbox mailboxes =
151 List.find_opt (fun m -> Mailbox.role m = Some `Inbox) mailboxes
152
153 (* Build a batched request using Chain *)
154 let fetch_inbox_emails ~account_id ~inbox_id =
155 let open Chain in
156 let* query = email_query ~account_id
157 ~filter:(Condition (Email_filter.make ~in_mailbox:inbox_id ()))
158 ~limit:50L ()
159 in
160 let* emails = email_get ~account_id
161 ~ids:(from_query query)
162 ~properties:["id"; "subject"; "from"; "receivedAt"; "keywords"]
163 ()
164 in
165 return emails
166 ]}
167
168 {2 Module Structure}
169
170 {b Core Types:}
171 - {!Id} - JMAP identifiers (validated strings)
172 - {!Keyword} - Email keywords as polymorphic variants ([`Seen], [`Flagged], ...)
173 - {!Role} - Mailbox roles ([`Inbox], [`Sent], [`Drafts], ...)
174 - {!module-Error} - Unified error type for all JMAP operations
175
176 {b Data Types:}
177 - {!Session} - Server capabilities and account information
178 - {!Email}, {!Mailbox}, {!Thread} - Mail objects with accessor functions
179 - {!Email_filter}, {!Mailbox_filter} - Query filter builders
180
181 {b Request Building:}
182 - {!Chain} - Monadic interface for batched requests with back-references
183 - {!Proto} - Low-level protocol types (rarely needed directly)
184
185 {2 References}
186
187 - {{:https://datatracker.ietf.org/doc/html/rfc8620}RFC 8620} - JMAP Core
188 - {{:https://datatracker.ietf.org/doc/html/rfc8621}RFC 8621} - JMAP for Mail
189 - {{:https://jmap.io}jmap.io} - Official JMAP website with guides
190*)
191
192(** {1 Protocol Layer Re-exports} *)
193
194(** Low-level JMAP protocol types (RFC 8620/8621).
195
196 These are the raw protocol and mail types. For most use cases, prefer the
197 higher-level types in this module. *)
198module Proto = Jmap_proto
199
200(** {1 Core Types} *)
201
202(** Unified error type for JMAP operations. *)
203module Error : sig
204 (** Request-level error (RFC 7807 Problem Details). *)
205 type request = {
206 type_ : string;
207 status : int option;
208 title : string option;
209 detail : string option;
210 limit : string option;
211 }
212
213 (** Method-level error. *)
214 type method_ = {
215 type_ : string;
216 description : string option;
217 }
218
219 (** Set operation error for a specific object. *)
220 type set = {
221 type_ : string;
222 description : string option;
223 properties : string list option;
224 }
225
226 (** Unified error type.
227
228 All errors from JSON parsing, HTTP, session management, and JMAP method
229 calls are represented as polymorphic variants. *)
230 type t = [
231 | `Request of request
232 | `Method of method_
233 | `Set of string * set
234 | `Json of string
235 | `Http of int * string
236 | `Connection of string
237 | `Session of string
238 ]
239
240 val pp : Format.formatter -> t -> unit
241 val to_string : t -> string
242end
243
244(** JMAP identifier type. *)
245module Id : sig
246 type t
247
248 val of_string : string -> (t, string) result
249 val of_string_exn : string -> t
250 val to_string : t -> string
251 val compare : t -> t -> int
252 val equal : t -> t -> bool
253 val pp : Format.formatter -> t -> unit
254end
255
256(** Email keyword type.
257
258 Standard keywords are represented as polymorphic variants.
259 Custom keywords use [`Custom of string]. *)
260module Keyword : sig
261 (** RFC 8621 standard keywords *)
262 type standard = [
263 | `Seen
264 | `Flagged
265 | `Answered
266 | `Draft
267 | `Forwarded
268 | `Phishing
269 | `Junk
270 | `NotJunk
271 ]
272
273 (** draft-ietf-mailmaint extended keywords *)
274 type extended = [
275 | `Notify
276 | `Muted
277 | `Followed
278 | `Memo
279 | `HasMemo
280 | `HasAttachment
281 | `HasNoAttachment
282 | `AutoSent
283 | `Unsubscribed
284 | `CanUnsubscribe
285 | `Imported
286 | `IsTrusted
287 | `MaskedEmail
288 | `New
289 ]
290
291 (** Apple Mail flag color keywords *)
292 type flag_bits = [
293 | `MailFlagBit0
294 | `MailFlagBit1
295 | `MailFlagBit2
296 ]
297
298 type t = [
299 | standard
300 | extended
301 | flag_bits
302 | `Custom of string
303 ]
304
305 val of_string : string -> t
306 val to_string : t -> string
307 val pp : Format.formatter -> t -> unit
308
309 (** Apple Mail flag colors *)
310 type flag_color = [
311 | `Red
312 | `Orange
313 | `Yellow
314 | `Green
315 | `Blue
316 | `Purple
317 | `Gray
318 ]
319
320 val flag_color_of_keywords : t list -> flag_color option
321 (** [flag_color_of_keywords keywords] extracts the flag color from a list
322 of keywords. Returns [None] for invalid bit combinations. *)
323
324 val flag_color_to_keywords : flag_color -> t list
325 (** [flag_color_to_keywords color] returns the keywords to set for the color. *)
326end
327
328(** Mailbox role type.
329
330 Standard roles are represented as polymorphic variants.
331 Custom roles use [`Custom of string]. *)
332module Role : sig
333 (** RFC 8621 standard roles *)
334 type standard = [
335 | `Inbox
336 | `Sent
337 | `Drafts
338 | `Trash
339 | `Junk
340 | `Archive
341 | `Flagged
342 | `Important
343 | `All
344 | `Subscribed
345 ]
346
347 (** draft-ietf-mailmaint extended roles *)
348 type extended = [
349 | `Snoozed
350 | `Scheduled
351 | `Memos
352 ]
353
354 type t = [
355 | standard
356 | extended
357 | `Custom of string
358 ]
359
360 val of_string : string -> t
361 val to_string : t -> string
362 val pp : Format.formatter -> t -> unit
363end
364
365(** JMAP capability type.
366
367 Standard capabilities are represented as polymorphic variants.
368 Custom capabilities use [`Custom of string]. *)
369module Capability : sig
370 type t = [
371 | `Core
372 | `Mail
373 | `Submission
374 | `VacationResponse
375 | `Custom of string
376 ]
377
378 val core_uri : string
379 val mail_uri : string
380 val submission_uri : string
381 val vacation_uri : string
382
383 val of_string : string -> t
384 val to_string : t -> string
385 val pp : Format.formatter -> t -> unit
386end
387
388(** {1 Session Types} *)
389
390(** JMAP session information. *)
391module Session : sig
392 (** Account information. *)
393 module Account : sig
394 type t
395
396 val name : t -> string
397 val is_personal : t -> bool
398 val is_read_only : t -> bool
399 end
400
401 type t
402
403 val capabilities : t -> (string * Jsont.json) list
404 val accounts : t -> (Id.t * Account.t) list
405 val primary_accounts : t -> (string * Id.t) list
406 val username : t -> string
407 val api_url : t -> string
408 val download_url : t -> string
409 val upload_url : t -> string
410 val event_source_url : t -> string
411 val state : t -> string
412
413 val get_account : Id.t -> t -> Account.t option
414 val primary_account_for : string -> t -> Id.t option
415 val has_capability : string -> t -> bool
416end
417
418(** {1 Mail Types} *)
419
420(** Email address with optional display name. *)
421module Email_address : sig
422 type t
423
424 val name : t -> string option
425 val email : t -> string
426 val create : ?name:string -> string -> t
427end
428
429(** Email mailbox.
430 All accessors return option types since responses only include requested properties. *)
431module Mailbox : sig
432 type t
433
434 val id : t -> Id.t option
435 val name : t -> string option
436 val parent_id : t -> Id.t option
437 val sort_order : t -> int64 option
438 val total_emails : t -> int64 option
439 val unread_emails : t -> int64 option
440 val total_threads : t -> int64 option
441 val unread_threads : t -> int64 option
442 val is_subscribed : t -> bool option
443 val role : t -> Role.t option
444
445 (** Mailbox rights. *)
446 module Rights : sig
447 type t
448
449 val may_read_items : t -> bool
450 val may_add_items : t -> bool
451 val may_remove_items : t -> bool
452 val may_set_seen : t -> bool
453 val may_set_keywords : t -> bool
454 val may_create_child : t -> bool
455 val may_rename : t -> bool
456 val may_delete : t -> bool
457 val may_submit : t -> bool
458 end
459
460 val my_rights : t -> Rights.t option
461end
462
463(** Email thread.
464 All accessors return option types since responses only include requested properties. *)
465module Thread : sig
466 type t
467
468 val id : t -> Id.t option
469 val email_ids : t -> Id.t list option
470end
471
472(** Email message. *)
473module Email : sig
474 (** Email body part. *)
475 module Body : sig
476 type part
477 type value
478
479 val part_id : part -> string option
480 val blob_id : part -> Id.t option
481 val size : part -> int64 option
482 val name : part -> string option
483 val type_ : part -> string
484 val charset : part -> string option
485 val disposition : part -> string option
486 val cid : part -> string option
487 val language : part -> string list option
488 val location : part -> string option
489
490 val value_text : value -> string
491 val value_is_truncated : value -> bool
492 val value_is_encoding_problem : value -> bool
493 end
494
495 (** All accessors return option types since responses only include requested properties. *)
496 type t
497
498 val id : t -> Id.t option
499 val blob_id : t -> Id.t option
500 val thread_id : t -> Id.t option
501 val mailbox_ids : t -> (Id.t * bool) list option
502 val size : t -> int64 option
503 val received_at : t -> Ptime.t option
504 val message_id : t -> string list option
505 val in_reply_to : t -> string list option
506 val references : t -> string list option
507 val subject : t -> string option
508 val sent_at : t -> Ptime.t option
509 val has_attachment : t -> bool option
510 val preview : t -> string option
511
512 (** Get active keywords as polymorphic variants.
513 Returns empty list if keywords property was not requested. *)
514 val keywords : t -> Keyword.t list
515
516 (** Check if email has a specific keyword.
517 Returns false if keywords property was not requested. *)
518 val has_keyword : Keyword.t -> t -> bool
519
520 val from : t -> Email_address.t list option
521 val to_ : t -> Email_address.t list option
522 val cc : t -> Email_address.t list option
523 val bcc : t -> Email_address.t list option
524 val reply_to : t -> Email_address.t list option
525 val sender : t -> Email_address.t list option
526
527 val text_body : t -> Body.part list option
528 val html_body : t -> Body.part list option
529 val attachments : t -> Body.part list option
530 val body_values : t -> (string * Body.value) list option
531end
532
533(** Email identity for sending.
534 All accessors return option types since responses only include requested properties. *)
535module Identity : sig
536 type t
537
538 val id : t -> Id.t option
539 val name : t -> string option
540 val email : t -> string option
541 val reply_to : t -> Email_address.t list option
542 val bcc : t -> Email_address.t list option
543 val text_signature : t -> string option
544 val html_signature : t -> string option
545 val may_delete : t -> bool option
546end
547
548(** Email submission for outgoing mail.
549 All accessors return option types since responses only include requested properties. *)
550module Submission : sig
551 type t
552
553 val id : t -> Id.t option
554 val identity_id : t -> Id.t option
555 val email_id : t -> Id.t option
556 val thread_id : t -> Id.t option
557 val send_at : t -> Ptime.t option
558 val undo_status : t -> Proto.Submission.undo_status option
559 val delivery_status : t -> (string * Proto.Submission.Delivery_status.t) list option
560 val dsn_blob_ids : t -> Id.t list option
561 val mdn_blob_ids : t -> Id.t list option
562end
563
564(** Vacation auto-response. *)
565module Vacation : sig
566 type t
567
568 val id : t -> Id.t
569 val is_enabled : t -> bool
570 val from_date : t -> Ptime.t option
571 val to_date : t -> Ptime.t option
572 val subject : t -> string option
573 val text_body : t -> string option
574 val html_body : t -> string option
575end
576
577(** Search snippet with highlighted matches. *)
578module Search_snippet : sig
579 type t
580
581 val email_id : t -> Id.t
582 val subject : t -> string option
583 val preview : t -> string option
584end
585
586(** {1 Filter Types} *)
587
588(** Email filter conditions for queries. *)
589module Email_filter : sig
590 type condition
591
592 (** Create an email filter condition.
593
594 All parameters are optional. Omitted parameters are not included
595 in the filter. Use [make ()] for an empty filter. *)
596 val make :
597 ?in_mailbox:Id.t ->
598 ?in_mailbox_other_than:Id.t list ->
599 ?before:Ptime.t ->
600 ?after:Ptime.t ->
601 ?min_size:int64 ->
602 ?max_size:int64 ->
603 ?all_in_thread_have_keyword:Keyword.t ->
604 ?some_in_thread_have_keyword:Keyword.t ->
605 ?none_in_thread_have_keyword:Keyword.t ->
606 ?has_keyword:Keyword.t ->
607 ?not_keyword:Keyword.t ->
608 ?has_attachment:bool ->
609 ?text:string ->
610 ?from:string ->
611 ?to_:string ->
612 ?cc:string ->
613 ?bcc:string ->
614 ?subject:string ->
615 ?body:string ->
616 ?header:(string * string option) ->
617 unit -> condition
618end
619
620(** Mailbox filter conditions for queries. *)
621module Mailbox_filter : sig
622 type condition
623
624 (** Create a mailbox filter condition.
625
626 All parameters are optional.
627 For [role]: [Some (Some r)] filters by role [r], [Some None] filters for
628 mailboxes with no role, [None] doesn't filter by role. *)
629 val make :
630 ?parent_id:Id.t option ->
631 ?name:string ->
632 ?role:Role.t option ->
633 ?has_any_role:bool ->
634 ?is_subscribed:bool ->
635 unit -> condition
636end
637
638(** {1 Response Types} *)
639
640(** Generic /get response wrapper. *)
641module Get_response : sig
642 type 'a t
643
644 val account_id : 'a t -> Id.t
645 val state : 'a t -> string
646 val list : 'a t -> 'a list
647 val not_found : 'a t -> Id.t list
648end
649
650(** Query response. *)
651module Query_response : sig
652 type t
653
654 val account_id : t -> Id.t
655 val query_state : t -> string
656 val can_calculate_changes : t -> bool
657 val position : t -> int64
658 val ids : t -> Id.t list
659 val total : t -> int64 option
660end
661
662(** Changes response. *)
663module Changes_response : sig
664 type t
665
666 val account_id : t -> Id.t
667 val old_state : t -> string
668 val new_state : t -> string
669 val has_more_changes : t -> bool
670 val created : t -> Id.t list
671 val updated : t -> Id.t list
672 val destroyed : t -> Id.t list
673end
674
675(** {1 JSONABLE Interface} *)
676
677(** Module type for types that can be serialized to/from JSON bytes. *)
678module type JSONABLE = sig
679 type t
680
681 val of_string : string -> (t, Error.t) result
682 val to_string : t -> (string, Error.t) result
683end
684
685(** {1 Request Chaining} *)
686
687(** JMAP method chaining with automatic result references.
688
689 This module provides a monadic interface for building JMAP requests
690 where method calls can reference results from previous calls. *)
691module Chain = Chain