this repo has no description
at main 691 lines 19 kB view raw
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