+20
-1
.gitignore
+20
-1
.gitignore
···
1
-
_build
1
+
# OCaml build artifacts
2
+
_build/
3
+
*.install
4
+
*.merlin
5
+
6
+
# Third-party sources (fetch locally with opam source)
7
+
third_party/
8
+
9
+
# Editor and OS files
10
+
.DS_Store
11
+
*.swp
12
+
*~
13
+
.vscode/
14
+
.idea/
15
+
16
+
# Opam local switch
17
+
_opam/
18
+
19
+
# Environment and secrets
2
20
.env
3
21
.api-key
22
+
.api-key-rw
4
23
.api-url
+1
.ocamlformat
+1
.ocamlformat
···
1
+
version=0.28.1
+53
.tangled/workflows/build.yml
+53
.tangled/workflows/build.yml
···
1
+
when:
2
+
- event: ["push", "pull_request"]
3
+
branch: ["main"]
4
+
5
+
engine: nixery
6
+
7
+
dependencies:
8
+
nixpkgs:
9
+
- shell
10
+
- stdenv
11
+
- findutils
12
+
- binutils
13
+
- libunwind
14
+
- ncurses
15
+
- opam
16
+
- git
17
+
- gawk
18
+
- gnupatch
19
+
- gnum4
20
+
- gnumake
21
+
- gnutar
22
+
- gnused
23
+
- gnugrep
24
+
- diffutils
25
+
- gzip
26
+
- bzip2
27
+
- gcc
28
+
- ocaml
29
+
- pkg-config
30
+
31
+
steps:
32
+
- name: opam
33
+
command: |
34
+
opam init --disable-sandboxing -a -y
35
+
- name: repo
36
+
command: |
37
+
opam repo add aoah https://tangled.org/anil.recoil.org/aoah-opam-repo.git
38
+
- name: switch
39
+
command: |
40
+
opam install . --confirm-level=unsafe-yes --deps-only
41
+
- name: build
42
+
command: |
43
+
opam exec -- dune build -p jmap
44
+
- name: switch-test
45
+
command: |
46
+
opam install . --confirm-level=unsafe-yes --deps-only --with-test
47
+
- name: test
48
+
command: |
49
+
opam exec -- dune runtest --verbose
50
+
- name: doc
51
+
command: |
52
+
opam install -y odoc
53
+
opam exec -- dune build @doc
+15
LICENSE.md
+15
LICENSE.md
···
1
+
ISC License
2
+
3
+
Copyright (c) 2025 Anil Madhavapeddy <anil@recoil.org>
4
+
5
+
Permission to use, copy, modify, and distribute this software for any
6
+
purpose with or without fee is hereby granted, provided that the above
7
+
copyright notice and this permission notice appear in all copies.
8
+
9
+
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES
10
+
WITH REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF
11
+
MERCHANTABILITY AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR
12
+
ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
13
+
WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
14
+
ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
15
+
OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
+54
README.md
+54
README.md
···
1
+
# ocaml-jmap - JMAP Protocol Implementation for OCaml
2
+
3
+
A complete implementation of the JSON Meta Application Protocol (JMAP) as specified in RFC 8620 (core) and RFC 8621 (mail).
4
+
5
+
## Packages
6
+
7
+
- **jmap** - Core JMAP protocol types and serialization
8
+
- **jmap-eio** - JMAP client using Eio for async I/O
9
+
- **jmap-brr** - JMAP client for browsers using js_of_ocaml
10
+
11
+
## Key Features
12
+
13
+
- Full RFC 8620 (JMAP Core) support: sessions, accounts, method calls, and error handling
14
+
- Full RFC 8621 (JMAP Mail) support: mailboxes, emails, threads, identities, and submissions
15
+
- Type-safe API with comprehensive type definitions
16
+
- Multiple backends: Eio for native async, Brr for browser-based clients
17
+
- JSON serialization via jsont
18
+
19
+
## Usage
20
+
21
+
```ocaml
22
+
(* Query emails from a mailbox *)
23
+
open Jmap
24
+
25
+
let query_emails ~client ~account_id ~mailbox_id =
26
+
let filter = Email.Query.Filter.(in_mailbox mailbox_id) in
27
+
let query = Email.Query.make ~account_id ~filter () in
28
+
Client.call client query
29
+
```
30
+
31
+
## Installation
32
+
33
+
```
34
+
opam install jmap jmap-eio
35
+
```
36
+
37
+
For browser-based applications:
38
+
39
+
```
40
+
opam install jmap jmap-brr
41
+
```
42
+
43
+
## Documentation
44
+
45
+
API documentation is available via:
46
+
47
+
```
48
+
opam install jmap
49
+
odig doc jmap
50
+
```
51
+
52
+
## License
53
+
54
+
ISC
+11
-2
bin/dune
+11
-2
bin/dune
···
1
1
(executable
2
2
(name jmap)
3
3
(public_name jmap)
4
-
(package jmap-eio)
4
+
(package jmap)
5
+
(optional)
5
6
(modules jmap)
6
-
(libraries jmap-eio eio_main))
7
+
(libraries jmap.eio eio_main))
8
+
9
+
(executable
10
+
(name jmapq)
11
+
(public_name jmapq)
12
+
(package jmap)
13
+
(optional)
14
+
(modules jmapq)
15
+
(libraries jmap.eio eio_main re jsont.bytesrw))
+841
-38
bin/jmap.ml
+841
-38
bin/jmap.ml
···
30
30
|> List.filter_map (fun (k, v) -> if v then Some k else None)
31
31
|> String.concat " "
32
32
33
+
(* Helpers for optional Email fields *)
34
+
let email_id (e : Jmap.Proto.Email.t) =
35
+
match e.id with Some id -> Jmap.Proto.Id.to_string id | None -> "?"
36
+
37
+
let email_received_at (e : Jmap.Proto.Email.t) =
38
+
match e.received_at with Some t -> ptime_to_string t | None -> "?"
39
+
40
+
let email_keywords (e : Jmap.Proto.Email.t) =
41
+
Option.value ~default:[] e.keywords
42
+
43
+
let email_preview (e : Jmap.Proto.Email.t) =
44
+
Option.value ~default:"" e.preview
45
+
46
+
let email_thread_id (e : Jmap.Proto.Email.t) =
47
+
match e.thread_id with Some id -> Jmap.Proto.Id.to_string id | None -> "?"
48
+
49
+
let email_size (e : Jmap.Proto.Email.t) =
50
+
Option.value ~default:0L e.size
51
+
52
+
let email_mailbox_ids (e : Jmap.Proto.Email.t) =
53
+
Option.value ~default:[] e.mailbox_ids
54
+
33
55
(** {1 Session Command} *)
34
56
35
57
let session_cmd =
···
98
120
result.state;
99
121
(* Sort by sort_order then name *)
100
122
let sorted = List.sort (fun (a : Jmap.Proto.Mailbox.t) (b : Jmap.Proto.Mailbox.t) ->
101
-
let cmp = Int64.compare a.sort_order b.sort_order in
102
-
if cmp <> 0 then cmp else String.compare a.name b.name
123
+
let sort_a = Option.value ~default:0L a.sort_order in
124
+
let sort_b = Option.value ~default:0L b.sort_order in
125
+
let cmp = Int64.compare sort_a sort_b in
126
+
let name_a = Option.value ~default:"" a.name in
127
+
let name_b = Option.value ~default:"" b.name in
128
+
if cmp <> 0 then cmp else String.compare name_a name_b
103
129
) result.list in
104
130
List.iter (fun (mbox : Jmap.Proto.Mailbox.t) ->
105
131
let role_str = match mbox.role with
106
132
| Some role -> Printf.sprintf " [%s]" (Jmap.Proto.Mailbox.role_to_string role)
107
133
| None -> ""
108
134
in
135
+
let id_str = match mbox.id with
136
+
| Some id -> Jmap.Proto.Id.to_string id
137
+
| None -> "?"
138
+
in
139
+
let name = Option.value ~default:"(unnamed)" mbox.name in
140
+
let total = Option.value ~default:0L mbox.total_emails in
141
+
let unread = Option.value ~default:0L mbox.unread_emails in
109
142
Fmt.pr " %a %s%a (%Ld total, %Ld unread)@,"
110
-
Fmt.(styled `Cyan string) (Jmap.Proto.Id.to_string mbox.id)
111
-
mbox.name
143
+
Fmt.(styled `Cyan string) id_str
144
+
name
112
145
Fmt.(styled `Yellow string) role_str
113
-
mbox.total_emails mbox.unread_emails
146
+
total unread
114
147
) sorted;
115
148
Fmt.pr "@]@."
116
149
in
···
228
261
| None -> "(unknown)"
229
262
in
230
263
let subject = Option.value email.subject ~default:"(no subject)" in
231
-
let flags = format_keywords email.keywords in
264
+
let keywords = Option.value ~default:[] email.keywords in
265
+
let flags = format_keywords keywords in
232
266
let flag_str = if flags = "" then "" else " [" ^ flags ^ "]" in
267
+
let id_str = match email.id with
268
+
| Some id -> Jmap.Proto.Id.to_string id
269
+
| None -> "?"
270
+
in
271
+
let received = match email.received_at with
272
+
| Some t -> ptime_to_string t
273
+
| None -> "?"
274
+
in
275
+
let preview = Option.value ~default:"" email.preview in
233
276
Fmt.pr " %a %s@,"
234
-
Fmt.(styled `Cyan string) (Jmap.Proto.Id.to_string email.id)
235
-
(ptime_to_string email.received_at);
277
+
Fmt.(styled `Cyan string) id_str
278
+
received;
236
279
Fmt.pr " From: %s@," (truncate_string 60 from_str);
237
280
Fmt.pr " Subject: %a%s@,"
238
281
Fmt.(styled `White string) (truncate_string 60 subject)
239
282
flag_str;
240
283
Fmt.pr " Preview: %s@,@,"
241
-
(truncate_string 70 email.preview);
284
+
(truncate_string 70 preview);
242
285
) get_result.list;
243
286
Fmt.pr "@]@."
244
287
)
···
349
392
| None -> "(unknown)"
350
393
in
351
394
let subject = Option.value email.subject ~default:"(no subject)" in
395
+
let id_str = match email.id with
396
+
| Some id -> Jmap.Proto.Id.to_string id
397
+
| None -> "?"
398
+
in
399
+
let received = match email.received_at with
400
+
| Some t -> ptime_to_string t
401
+
| None -> "?"
402
+
in
403
+
let preview = Option.value ~default:"" email.preview in
352
404
Fmt.pr " %a %s@,"
353
-
Fmt.(styled `Cyan string) (Jmap.Proto.Id.to_string email.id)
354
-
(ptime_to_string email.received_at);
405
+
Fmt.(styled `Cyan string) id_str
406
+
received;
355
407
Fmt.pr " From: %s@," (truncate_string 60 from_str);
356
408
Fmt.pr " Subject: %a@,"
357
409
Fmt.(styled `White string) (truncate_string 60 subject);
358
410
Fmt.pr " Preview: %s@,@,"
359
-
(truncate_string 70 email.preview);
411
+
(truncate_string 70 preview);
360
412
) get_result.list;
361
413
Fmt.pr "@]@."
362
414
)
···
460
512
| _ -> "?"
461
513
in
462
514
let subject = Option.value email.subject ~default:"(no subject)" in
463
-
let flags = format_keywords email.keywords in
515
+
let flags = format_keywords (email_keywords email) in
464
516
Printf.printf "%s\t%s\t%s\t%s\t%s\n"
465
-
(Jmap.Proto.Id.to_string email.id)
466
-
(ptime_to_string email.received_at)
517
+
(email_id email)
518
+
(email_received_at email)
467
519
(truncate_string 20 from_str)
468
520
(truncate_string 50 subject)
469
521
flags
···
485
537
| _ -> "?"
486
538
in
487
539
let subject = Option.value email.subject ~default:"(no subject)" in
488
-
let flags = format_keywords email.keywords in
540
+
let flags = format_keywords (email_keywords email) in
489
541
let id_short =
490
-
let id = Jmap.Proto.Id.to_string email.id in
542
+
let id = email_id email in
491
543
if String.length id > 12 then String.sub id 0 12 else id
492
544
in
493
545
Fmt.pr "%-12s %s %-20s %-40s %s@,"
494
546
id_short
495
-
(ptime_to_string email.received_at)
547
+
(email_received_at email)
496
548
(truncate_string 20 from_str)
497
549
(truncate_string 40 subject)
498
550
flags
···
517
569
| None -> ""
518
570
in
519
571
let subject = Option.value email.subject ~default:"(no subject)" in
520
-
let flags = format_keywords email.keywords in
521
-
let mailbox_count = List.length email.mailbox_ids in
572
+
let flags = format_keywords (email_keywords email) in
573
+
let mailbox_count = List.length (email_mailbox_ids email) in
522
574
523
575
Fmt.pr "@[<v 2>%a Email %d of %d@,"
524
576
Fmt.(styled `Bold string) "---"
525
577
(i + 1) (List.length get_result.list);
526
578
Fmt.pr "ID: %a@,"
527
-
Fmt.(styled `Cyan string) (Jmap.Proto.Id.to_string email.id);
528
-
Fmt.pr "Thread: %s@," (Jmap.Proto.Id.to_string email.thread_id);
529
-
Fmt.pr "Date: %s@," (ptime_to_string email.received_at);
579
+
Fmt.(styled `Cyan string) (email_id email);
580
+
Fmt.pr "Thread: %s@," (email_thread_id email);
581
+
Fmt.pr "Date: %s@," (email_received_at email);
530
582
Fmt.pr "From: %s@," from_str;
531
583
if to_str <> "" then Fmt.pr "To: %s@," to_str;
532
584
if cc_str <> "" then Fmt.pr "Cc: %s@," cc_str;
533
585
Fmt.pr "Subject: %a@,"
534
586
Fmt.(styled `White string) subject;
535
-
Fmt.pr "Size: %Ld bytes@," email.size;
587
+
Fmt.pr "Size: %Ld bytes@," (email_size email);
536
588
Fmt.pr "Mailboxes: %d@," mailbox_count;
537
589
if flags <> "" then Fmt.pr "Flags: %s@," flags;
538
-
Fmt.pr "Preview: %s@]@,@," email.preview;
590
+
Fmt.pr "Preview: %s@]@,@," (email_preview email);
539
591
) get_result.list;
540
592
Fmt.pr "@]@."
541
593
)
···
557
609
let client = Jmap_eio.Cli.create_client ~sw env cfg in
558
610
let account_id = Jmap_eio.Cli.get_account_id cfg client in
559
611
560
-
let email_id = Jmap.Proto.Id.of_string_exn email_id_str in
612
+
let target_email_id = Jmap.Proto.Id.of_string_exn email_id_str in
561
613
562
614
(* First get the email to find its thread ID - include required properties *)
563
615
let get_inv = Jmap_eio.Client.Build.email_get
564
616
~call_id:"e1"
565
617
~account_id
566
-
~ids:[email_id]
618
+
~ids:[target_email_id]
567
619
~properties:["id"; "blobId"; "threadId"; "mailboxIds"; "size"; "receivedAt"]
568
620
()
569
621
in
···
588
640
Fmt.epr "Email not found: %s@." email_id_str;
589
641
exit 1
590
642
| email :: _ ->
591
-
let thread_id = email.thread_id in
643
+
let thread_id = match email.thread_id with
644
+
| Some id -> id
645
+
| None ->
646
+
Fmt.epr "Email has no thread ID@.";
647
+
exit 1
648
+
in
592
649
Jmap_eio.Cli.debug cfg "Thread ID: %s" (Jmap.Proto.Id.to_string thread_id);
593
650
594
651
(* Get the thread *)
···
619
676
Fmt.epr "Thread not found@.";
620
677
exit 1
621
678
| thread :: _ ->
622
-
let email_ids = thread.email_ids in
679
+
let thread_id_str = match thread.id with
680
+
| Some id -> Jmap.Proto.Id.to_string id
681
+
| None -> "?"
682
+
in
683
+
let email_ids = Option.value ~default:[] thread.email_ids in
623
684
Fmt.pr "@[<v>%a %s (%d emails)@,@,"
624
685
Fmt.(styled `Bold string) "Thread"
625
-
(Jmap.Proto.Id.to_string thread.id)
686
+
thread_id_str
626
687
(List.length email_ids);
627
688
628
689
(* Fetch all emails in thread *)
···
657
718
in
658
719
let subject = Option.value email.subject ~default:"(no subject)" in
659
720
Fmt.pr " %a %s@,"
660
-
Fmt.(styled `Cyan string) (Jmap.Proto.Id.to_string email.id)
661
-
(ptime_to_string email.received_at);
721
+
Fmt.(styled `Cyan string) (email_id email)
722
+
(email_received_at email);
662
723
Fmt.pr " From: %s@," (truncate_string 60 from_str);
663
724
Fmt.pr " Subject: %a@,@,"
664
725
Fmt.(styled `White string) (truncate_string 60 subject);
···
701
762
Fmt.(styled `Bold string) "Identities"
702
763
result.state;
703
764
List.iter (fun (ident : Jmap.Proto.Identity.t) ->
765
+
let ident_id = match ident.id with Some id -> Jmap.Proto.Id.to_string id | None -> "?" in
766
+
let ident_name = Option.value ~default:"(unnamed)" ident.name in
767
+
let ident_email = Option.value ~default:"(no email)" ident.email in
768
+
let ident_sig = Option.value ~default:"" ident.text_signature in
769
+
let ident_may_delete = Option.value ~default:false ident.may_delete in
704
770
Fmt.pr " %a@,"
705
-
Fmt.(styled `Cyan string) (Jmap.Proto.Id.to_string ident.id);
706
-
Fmt.pr " Name: %s@," ident.name;
771
+
Fmt.(styled `Cyan string) ident_id;
772
+
Fmt.pr " Name: %s@," ident_name;
707
773
Fmt.pr " Email: %a@,"
708
-
Fmt.(styled `Green string) ident.email;
709
-
if ident.text_signature <> "" then
710
-
Fmt.pr " Signature: %s@," (truncate_string 50 ident.text_signature);
711
-
Fmt.pr " May delete: %b@,@," ident.may_delete
774
+
Fmt.(styled `Green string) ident_email;
775
+
if ident_sig <> "" then
776
+
Fmt.pr " Signature: %s@," (truncate_string 50 ident_sig);
777
+
Fmt.pr " May delete: %b@,@," ident_may_delete
712
778
) result.list;
713
779
Fmt.pr "@]@."
714
780
in
···
716
782
let info = Cmd.info "identities" ~doc in
717
783
Cmd.v info Term.(const run $ Jmap_eio.Cli.config_term)
718
784
785
+
(** {1 Chained Commands - Using the Chain monad} *)
786
+
787
+
(** Inbox command - demonstrates simple query+get chain *)
788
+
let inbox_cmd =
789
+
let limit_term =
790
+
let doc = "Maximum number of emails to show" in
791
+
Arg.(value & opt int 20 & info ["limit"; "n"] ~docv:"N" ~doc)
792
+
in
793
+
let run cfg limit =
794
+
Eio_main.run @@ fun env ->
795
+
Eio.Switch.run @@ fun sw ->
796
+
let client = Jmap_eio.Cli.create_client ~sw env cfg in
797
+
let account_id = Jmap_eio.Cli.get_account_id cfg client in
798
+
799
+
Jmap_eio.Cli.debug cfg "Fetching inbox emails using Chain API";
800
+
801
+
(* Find inbox mailbox first *)
802
+
let mbox_req = Jmap_eio.Client.Build.(
803
+
make_request
804
+
~capabilities:[Jmap.Proto.Capability.core; Jmap.Proto.Capability.mail]
805
+
[mailbox_get ~call_id:"m1" ~account_id ()]
806
+
) in
807
+
808
+
match Jmap_eio.Client.request client mbox_req with
809
+
| Error e ->
810
+
Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e);
811
+
exit 1
812
+
| Ok mbox_response ->
813
+
match Jmap_eio.Client.Parse.parse_mailbox_get ~call_id:"m1" mbox_response with
814
+
| Error e ->
815
+
Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e);
816
+
exit 1
817
+
| Ok mbox_result ->
818
+
(* Find inbox *)
819
+
let inbox =
820
+
List.find_opt (fun (m : Jmap.Proto.Mailbox.t) ->
821
+
m.role = Some `Inbox
822
+
) mbox_result.list
823
+
in
824
+
match inbox with
825
+
| None ->
826
+
Fmt.epr "No inbox found@.";
827
+
exit 1
828
+
| Some inbox ->
829
+
let inbox_id = match inbox.id with
830
+
| Some id -> id
831
+
| None ->
832
+
Fmt.epr "Inbox has no ID@.";
833
+
exit 1
834
+
in
835
+
Jmap_eio.Cli.debug cfg "Found inbox: %s" (Jmap.Proto.Id.to_string inbox_id);
836
+
837
+
(* Now use Chain API to query and get emails in one request *)
838
+
let open Jmap_eio.Chain in
839
+
let filter_cond : Jmap.Proto.Email.Filter_condition.t = {
840
+
in_mailbox = Some inbox_id;
841
+
in_mailbox_other_than = None;
842
+
before = None; after = None;
843
+
min_size = None; max_size = None;
844
+
all_in_thread_have_keyword = None;
845
+
some_in_thread_have_keyword = None;
846
+
none_in_thread_have_keyword = None;
847
+
has_keyword = None; not_keyword = None;
848
+
has_attachment = None;
849
+
text = None; from = None; to_ = None;
850
+
cc = None; bcc = None; subject = None;
851
+
body = None; header = None;
852
+
} in
853
+
let sort = [Jmap.Proto.Filter.comparator ~is_ascending:false "receivedAt"] in
854
+
855
+
let request, email_handle = build
856
+
~capabilities:[Jmap.Proto.Capability.core; Jmap.Proto.Capability.mail]
857
+
begin
858
+
let* query = email_query ~account_id
859
+
~filter:(Jmap.Proto.Filter.Condition filter_cond)
860
+
~sort
861
+
~limit:(Int64.of_int limit)
862
+
()
863
+
in
864
+
let* emails = email_get ~account_id
865
+
~ids:(from_query query)
866
+
~properties:["id"; "blobId"; "threadId"; "mailboxIds"; "size"; "receivedAt"; "subject"; "from"; "preview"; "keywords"]
867
+
()
868
+
in
869
+
return emails
870
+
end in
871
+
872
+
Jmap_eio.Cli.debug cfg "Sending chained request (query + get in one round trip)";
873
+
874
+
match Jmap_eio.Client.request client request with
875
+
| Error e ->
876
+
Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e);
877
+
exit 1
878
+
| Ok response ->
879
+
match parse email_handle response with
880
+
| Error e ->
881
+
Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e);
882
+
exit 1
883
+
| Ok result ->
884
+
Fmt.pr "@[<v>%a (%d emails in inbox)@,@,"
885
+
Fmt.(styled `Bold string) "Inbox"
886
+
(List.length result.list);
887
+
List.iter (fun (email : Jmap.Proto.Email.t) ->
888
+
let from_str = match email.from with
889
+
| Some (addr :: _) ->
890
+
Option.value addr.name ~default:addr.email
891
+
| _ -> "?"
892
+
in
893
+
let subject = Option.value email.subject ~default:"(no subject)" in
894
+
let flags = format_keywords (email_keywords email) in
895
+
Fmt.pr " %a %s@,"
896
+
Fmt.(styled `Cyan string) (email_id email)
897
+
(email_received_at email);
898
+
Fmt.pr " From: %s@," (truncate_string 40 from_str);
899
+
Fmt.pr " Subject: %a%s@,"
900
+
Fmt.(styled `White string) (truncate_string 50 subject)
901
+
(if flags = "" then "" else " [" ^ flags ^ "]");
902
+
Fmt.pr "@,"
903
+
) result.list;
904
+
Fmt.pr "@]@."
905
+
in
906
+
let doc = "List inbox emails (uses Chain API for query+get in single request)" in
907
+
let info = Cmd.info "inbox" ~doc in
908
+
Cmd.v info Term.(const run $ Jmap_eio.Cli.config_term $ limit_term)
909
+
910
+
(** Thread-view command - demonstrates multi-step chaining (RFC 8620 example) *)
911
+
let thread_view_cmd =
912
+
let limit_term =
913
+
let doc = "Number of threads to show" in
914
+
Arg.(value & opt int 10 & info ["limit"; "n"] ~docv:"N" ~doc)
915
+
in
916
+
let run cfg limit =
917
+
Eio_main.run @@ fun env ->
918
+
Eio.Switch.run @@ fun sw ->
919
+
let client = Jmap_eio.Cli.create_client ~sw env cfg in
920
+
let account_id = Jmap_eio.Cli.get_account_id cfg client in
921
+
922
+
Jmap_eio.Cli.debug cfg "Fetching threaded view using multi-step Chain API";
923
+
924
+
(*
925
+
This implements the RFC 8620 example:
926
+
1. Email/query with collapseThreads to get one email per thread
927
+
2. Email/get to fetch threadId for each
928
+
3. Thread/get to fetch all emailIds in each thread
929
+
4. Email/get to fetch details for all emails in those threads
930
+
*)
931
+
let open Jmap_eio.Chain in
932
+
let sort = [Jmap.Proto.Filter.comparator ~is_ascending:false "receivedAt"] in
933
+
934
+
let request, (query_h, final_emails_h) = build
935
+
~capabilities:[Jmap.Proto.Capability.core; Jmap.Proto.Capability.mail]
936
+
begin
937
+
(* Step 1: Query for recent emails, collapsing threads *)
938
+
let* query = email_query ~account_id
939
+
~sort
940
+
~collapse_threads:true
941
+
~limit:(Int64.of_int limit)
942
+
()
943
+
in
944
+
(* Step 2: Get just threadId for those emails *)
945
+
let* emails1 = email_get ~account_id
946
+
~ids:(from_query query)
947
+
~properties:["id"; "blobId"; "threadId"; "mailboxIds"; "size"; "receivedAt"]
948
+
()
949
+
in
950
+
(* Step 3: Get threads using threadIds from step 2 *)
951
+
let* threads = thread_get ~account_id
952
+
~ids:(from_get_field emails1 "threadId")
953
+
()
954
+
in
955
+
(* Step 4: Get all emails in those threads *)
956
+
let* emails2 = email_get ~account_id
957
+
~ids:(from_get_field threads "emailIds")
958
+
~properties:["id"; "blobId"; "threadId"; "mailboxIds"; "size"; "receivedAt"; "subject"; "from"; "preview"]
959
+
()
960
+
in
961
+
return (query, emails2)
962
+
end in
963
+
964
+
Jmap_eio.Cli.debug cfg "Sending 4-step chained request in single round trip";
965
+
966
+
match Jmap_eio.Client.request client request with
967
+
| Error e ->
968
+
Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e);
969
+
exit 1
970
+
| Ok response ->
971
+
let query_result = parse_exn query_h response in
972
+
let emails_result = parse_exn final_emails_h response in
973
+
974
+
(* Group emails by thread *)
975
+
let threads_map = Hashtbl.create 16 in
976
+
List.iter (fun (email : Jmap.Proto.Email.t) ->
977
+
let tid = email_thread_id email in
978
+
let existing = try Hashtbl.find threads_map tid with Not_found -> [] in
979
+
Hashtbl.replace threads_map tid (email :: existing)
980
+
) emails_result.list;
981
+
982
+
Fmt.pr "@[<v>%a (%d threads, %d total emails)@,@,"
983
+
Fmt.(styled `Bold string) "Threaded View"
984
+
(Hashtbl.length threads_map)
985
+
(List.length emails_result.list);
986
+
Fmt.pr "Query found %s total matching emails@,@,"
987
+
(match query_result.total with Some n -> Int64.to_string n | None -> "?");
988
+
989
+
(* Print threads *)
990
+
Hashtbl.iter (fun _tid emails ->
991
+
let emails = List.sort (fun (a : Jmap.Proto.Email.t) (b : Jmap.Proto.Email.t) ->
992
+
let a_time = Option.value ~default:Ptime.epoch a.received_at in
993
+
let b_time = Option.value ~default:Ptime.epoch b.received_at in
994
+
Ptime.compare a_time b_time
995
+
) emails in
996
+
let first_email = List.hd emails in
997
+
let subject = Option.value first_email.subject ~default:"(no subject)" in
998
+
Fmt.pr " %a Thread: %s (%d emails)@,"
999
+
Fmt.(styled `Bold string) "โธ"
1000
+
(truncate_string 50 subject)
1001
+
(List.length emails);
1002
+
List.iter (fun (email : Jmap.Proto.Email.t) ->
1003
+
let from_str = match email.from with
1004
+
| Some (addr :: _) -> Option.value addr.name ~default:addr.email
1005
+
| _ -> "?"
1006
+
in
1007
+
Fmt.pr " %s %s %s@,"
1008
+
(email_id email |> truncate_string 12)
1009
+
(email_received_at email)
1010
+
(truncate_string 30 from_str)
1011
+
) emails;
1012
+
Fmt.pr "@,"
1013
+
) threads_map;
1014
+
Fmt.pr "@]@."
1015
+
in
1016
+
let doc = "Show threaded view (demonstrates RFC 8620 multi-step chain)" in
1017
+
let info = Cmd.info "thread-view" ~doc in
1018
+
Cmd.v info Term.(const run $ Jmap_eio.Cli.config_term $ limit_term)
1019
+
1020
+
(** Mark-read command - demonstrates email_set for updating keywords *)
1021
+
let mark_read_cmd =
1022
+
let email_id_term =
1023
+
let doc = "Email ID to mark as read" in
1024
+
Arg.(required & pos 0 (some string) None & info [] ~docv:"EMAIL_ID" ~doc)
1025
+
in
1026
+
let unread_term =
1027
+
let doc = "Mark as unread instead of read" in
1028
+
Arg.(value & flag & info ["unread"; "u"] ~doc)
1029
+
in
1030
+
let run cfg email_id_str unread =
1031
+
Eio_main.run @@ fun env ->
1032
+
Eio.Switch.run @@ fun sw ->
1033
+
let client = Jmap_eio.Cli.create_client ~sw env cfg in
1034
+
let account_id = Jmap_eio.Cli.get_account_id cfg client in
1035
+
let email_id = Jmap.Proto.Id.of_string_exn email_id_str in
1036
+
1037
+
Jmap_eio.Cli.debug cfg "%s email %s"
1038
+
(if unread then "Marking as unread" else "Marking as read")
1039
+
email_id_str;
1040
+
1041
+
(* Build the patch object - set or unset $seen keyword *)
1042
+
let patch =
1043
+
let open Jmap_eio.Chain in
1044
+
if unread then
1045
+
json_obj [("keywords/$seen", json_null)]
1046
+
else
1047
+
json_obj [("keywords/$seen", json_bool true)]
1048
+
in
1049
+
1050
+
let open Jmap_eio.Chain in
1051
+
let request, set_h = build
1052
+
~capabilities:[Jmap.Proto.Capability.core; Jmap.Proto.Capability.mail]
1053
+
begin
1054
+
email_set ~account_id
1055
+
~update:[(email_id, patch)]
1056
+
()
1057
+
end in
1058
+
1059
+
match Jmap_eio.Client.request client request with
1060
+
| Error e ->
1061
+
Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e);
1062
+
exit 1
1063
+
| Ok response ->
1064
+
match parse set_h response with
1065
+
| Error e ->
1066
+
Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e);
1067
+
exit 1
1068
+
| Ok result ->
1069
+
(* Check if update succeeded *)
1070
+
let updated_ids =
1071
+
result.updated
1072
+
|> Option.value ~default:[]
1073
+
|> List.map (fun (id, _) -> Jmap.Proto.Id.to_string id)
1074
+
in
1075
+
if List.mem email_id_str updated_ids then
1076
+
Fmt.pr "Email %s marked as %s@."
1077
+
email_id_str
1078
+
(if unread then "unread" else "read")
1079
+
else (
1080
+
Fmt.epr "Failed to update email. ";
1081
+
let not_updated = Option.value ~default:[] result.not_updated in
1082
+
(match List.find_opt (fun (id, _) -> Jmap.Proto.Id.to_string id = email_id_str) not_updated with
1083
+
| Some (_, err) ->
1084
+
let open Jmap.Proto.Error in
1085
+
let err_type = set_error_type_to_string err.type_ in
1086
+
let err_desc = Option.value ~default:"" err.description in
1087
+
Fmt.epr "Error: %s (%s)@." err_type err_desc
1088
+
| None ->
1089
+
Fmt.epr "Unknown error@.");
1090
+
exit 1
1091
+
)
1092
+
in
1093
+
let doc = "Mark an email as read/unread (demonstrates Email/set)" in
1094
+
let info = Cmd.info "mark-read" ~doc in
1095
+
Cmd.v info Term.(const run $ Jmap_eio.Cli.config_term $ email_id_term $ unread_term)
1096
+
1097
+
(** Delete email command - demonstrates email_set destroy *)
1098
+
let delete_email_cmd =
1099
+
let email_ids_term =
1100
+
let doc = "Email IDs to delete" in
1101
+
Arg.(non_empty & pos_all string [] & info [] ~docv:"EMAIL_ID" ~doc)
1102
+
in
1103
+
let run cfg email_id_strs =
1104
+
Eio_main.run @@ fun env ->
1105
+
Eio.Switch.run @@ fun sw ->
1106
+
let client = Jmap_eio.Cli.create_client ~sw env cfg in
1107
+
let account_id = Jmap_eio.Cli.get_account_id cfg client in
1108
+
let email_ids = List.map Jmap.Proto.Id.of_string_exn email_id_strs in
1109
+
1110
+
Jmap_eio.Cli.debug cfg "Deleting %d email(s)" (List.length email_ids);
1111
+
1112
+
let open Jmap_eio.Chain in
1113
+
let request, set_h = build
1114
+
~capabilities:[Jmap.Proto.Capability.core; Jmap.Proto.Capability.mail]
1115
+
begin
1116
+
email_set ~account_id
1117
+
~destroy:(ids email_ids)
1118
+
()
1119
+
end in
1120
+
1121
+
match Jmap_eio.Client.request client request with
1122
+
| Error e ->
1123
+
Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e);
1124
+
exit 1
1125
+
| Ok response ->
1126
+
match parse set_h response with
1127
+
| Error e ->
1128
+
Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e);
1129
+
exit 1
1130
+
| Ok result ->
1131
+
let destroyed = Option.value ~default:[] result.destroyed in
1132
+
let destroyed_ids = List.map Jmap.Proto.Id.to_string destroyed in
1133
+
Fmt.pr "Deleted %d email(s):@." (List.length destroyed_ids);
1134
+
List.iter (fun id -> Fmt.pr " %s@." id) destroyed_ids;
1135
+
(* Report any failures *)
1136
+
let not_destroyed = Option.value ~default:[] result.not_destroyed in
1137
+
if not_destroyed <> [] then begin
1138
+
Fmt.epr "Failed to delete %d email(s):@." (List.length not_destroyed);
1139
+
List.iter (fun (id, err) ->
1140
+
let open Jmap.Proto.Error in
1141
+
let err_type = set_error_type_to_string err.type_ in
1142
+
Fmt.epr " %s: %s@."
1143
+
(Jmap.Proto.Id.to_string id)
1144
+
err_type
1145
+
) not_destroyed
1146
+
end
1147
+
in
1148
+
let doc = "Delete emails (demonstrates Email/set destroy)" in
1149
+
let info = Cmd.info "delete" ~doc in
1150
+
Cmd.v info Term.(const run $ Jmap_eio.Cli.config_term $ email_ids_term)
1151
+
1152
+
(** Changes command - demonstrates email_changes for sync *)
1153
+
let changes_cmd =
1154
+
let state_term =
1155
+
let doc = "State to get changes since (use 'current' to just show current state)" in
1156
+
Arg.(required & pos 0 (some string) None & info [] ~docv:"STATE" ~doc)
1157
+
in
1158
+
let run cfg state_str =
1159
+
Eio_main.run @@ fun env ->
1160
+
Eio.Switch.run @@ fun sw ->
1161
+
let client = Jmap_eio.Cli.create_client ~sw env cfg in
1162
+
let account_id = Jmap_eio.Cli.get_account_id cfg client in
1163
+
1164
+
if state_str = "current" then (
1165
+
(* Just get current state by doing a minimal query *)
1166
+
let open Jmap_eio.Chain in
1167
+
let request, get_h = build
1168
+
~capabilities:[Jmap.Proto.Capability.core; Jmap.Proto.Capability.mail]
1169
+
begin
1170
+
(* Get empty list just to see state *)
1171
+
email_get ~account_id ~ids:(ids []) ()
1172
+
end in
1173
+
1174
+
match Jmap_eio.Client.request client request with
1175
+
| Error e ->
1176
+
Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e);
1177
+
exit 1
1178
+
| Ok response ->
1179
+
match parse get_h response with
1180
+
| Error e ->
1181
+
Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e);
1182
+
exit 1
1183
+
| Ok result ->
1184
+
Fmt.pr "Current email state: %a@."
1185
+
Fmt.(styled `Cyan string) result.state
1186
+
) else (
1187
+
Jmap_eio.Cli.debug cfg "Getting changes since state: %s" state_str;
1188
+
1189
+
let open Jmap_eio.Chain in
1190
+
let request, changes_h = build
1191
+
~capabilities:[Jmap.Proto.Capability.core; Jmap.Proto.Capability.mail]
1192
+
begin
1193
+
email_changes ~account_id ~since_state:state_str ()
1194
+
end in
1195
+
1196
+
match Jmap_eio.Client.request client request with
1197
+
| Error e ->
1198
+
Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e);
1199
+
exit 1
1200
+
| Ok response ->
1201
+
match parse changes_h response with
1202
+
| Error e ->
1203
+
Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e);
1204
+
exit 1
1205
+
| Ok result ->
1206
+
Fmt.pr "@[<v>%a@,@,"
1207
+
Fmt.(styled `Bold string) "Email Changes";
1208
+
Fmt.pr "Old state: %s@," result.old_state;
1209
+
Fmt.pr "New state: %a@," Fmt.(styled `Cyan string) result.new_state;
1210
+
Fmt.pr "Has more changes: %b@,@," result.has_more_changes;
1211
+
Fmt.pr "Created: %d email(s)@," (List.length result.created);
1212
+
List.iter (fun id ->
1213
+
Fmt.pr " + %s@," (Jmap.Proto.Id.to_string id)
1214
+
) result.created;
1215
+
Fmt.pr "Updated: %d email(s)@," (List.length result.updated);
1216
+
List.iter (fun id ->
1217
+
Fmt.pr " ~ %s@," (Jmap.Proto.Id.to_string id)
1218
+
) result.updated;
1219
+
Fmt.pr "Destroyed: %d email(s)@," (List.length result.destroyed);
1220
+
List.iter (fun id ->
1221
+
Fmt.pr " - %s@," (Jmap.Proto.Id.to_string id)
1222
+
) result.destroyed;
1223
+
Fmt.pr "@]@."
1224
+
)
1225
+
in
1226
+
let doc = "Show email changes since a state (demonstrates Email/changes)" in
1227
+
let info = Cmd.info "changes" ~doc in
1228
+
Cmd.v info Term.(const run $ Jmap_eio.Cli.config_term $ state_term)
1229
+
1230
+
(** Sync command - demonstrates changes + get pattern for incremental sync *)
1231
+
let sync_cmd =
1232
+
let state_term =
1233
+
let doc = "State to sync from" in
1234
+
Arg.(required & pos 0 (some string) None & info [] ~docv:"STATE" ~doc)
1235
+
in
1236
+
let run cfg state_str =
1237
+
Eio_main.run @@ fun env ->
1238
+
Eio.Switch.run @@ fun sw ->
1239
+
let client = Jmap_eio.Cli.create_client ~sw env cfg in
1240
+
let account_id = Jmap_eio.Cli.get_account_id cfg client in
1241
+
1242
+
Jmap_eio.Cli.debug cfg "Syncing from state: %s" state_str;
1243
+
1244
+
(* Chain: changes โ get created โ get updated *)
1245
+
let open Jmap_eio.Chain in
1246
+
let request, (changes_h, created_h, updated_h) = build
1247
+
~capabilities:[Jmap.Proto.Capability.core; Jmap.Proto.Capability.mail]
1248
+
begin
1249
+
let* changes = email_changes ~account_id ~since_state:state_str () in
1250
+
let* created = email_get ~account_id
1251
+
~ids:(from_changes_created changes)
1252
+
~properties:["id"; "blobId"; "threadId"; "mailboxIds"; "size"; "receivedAt"; "subject"; "from"; "preview"]
1253
+
()
1254
+
in
1255
+
let* updated = email_get ~account_id
1256
+
~ids:(from_changes_updated changes)
1257
+
~properties:["id"; "blobId"; "threadId"; "mailboxIds"; "size"; "receivedAt"; "subject"; "from"; "keywords"]
1258
+
()
1259
+
in
1260
+
return (changes, created, updated)
1261
+
end in
1262
+
1263
+
Jmap_eio.Cli.debug cfg "Sending chained sync request";
1264
+
1265
+
match Jmap_eio.Client.request client request with
1266
+
| Error e ->
1267
+
Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e);
1268
+
exit 1
1269
+
| Ok response ->
1270
+
let changes_result = parse_exn changes_h response in
1271
+
let created_result = parse_exn created_h response in
1272
+
let updated_result = parse_exn updated_h response in
1273
+
1274
+
Fmt.pr "@[<v>%a (state: %s โ %s)@,@,"
1275
+
Fmt.(styled `Bold string) "Sync Results"
1276
+
changes_result.old_state
1277
+
changes_result.new_state;
1278
+
1279
+
if List.length created_result.list > 0 then begin
1280
+
Fmt.pr "%a (%d)@,"
1281
+
Fmt.(styled `Green string) "New emails"
1282
+
(List.length created_result.list);
1283
+
List.iter (fun (email : Jmap.Proto.Email.t) ->
1284
+
let from_str = match email.from with
1285
+
| Some (addr :: _) -> Option.value addr.name ~default:addr.email
1286
+
| _ -> "?"
1287
+
in
1288
+
let subject = Option.value email.subject ~default:"(no subject)" in
1289
+
Fmt.pr " + %s %s %s@,"
1290
+
(email_id email |> truncate_string 12)
1291
+
(truncate_string 20 from_str)
1292
+
(truncate_string 40 subject)
1293
+
) created_result.list;
1294
+
Fmt.pr "@,"
1295
+
end;
1296
+
1297
+
if List.length updated_result.list > 0 then begin
1298
+
Fmt.pr "%a (%d)@,"
1299
+
Fmt.(styled `Yellow string) "Updated emails"
1300
+
(List.length updated_result.list);
1301
+
List.iter (fun (email : Jmap.Proto.Email.t) ->
1302
+
let flags = format_keywords (email_keywords email) in
1303
+
Fmt.pr " ~ %s [%s]@,"
1304
+
(email_id email |> truncate_string 12)
1305
+
flags
1306
+
) updated_result.list;
1307
+
Fmt.pr "@,"
1308
+
end;
1309
+
1310
+
if List.length changes_result.destroyed > 0 then begin
1311
+
Fmt.pr "%a (%d)@,"
1312
+
Fmt.(styled `Red string) "Deleted emails"
1313
+
(List.length changes_result.destroyed);
1314
+
List.iter (fun id ->
1315
+
Fmt.pr " - %s@," (Jmap.Proto.Id.to_string id)
1316
+
) changes_result.destroyed;
1317
+
Fmt.pr "@,"
1318
+
end;
1319
+
1320
+
if changes_result.has_more_changes then
1321
+
Fmt.pr "%a - call sync again with state %s@,"
1322
+
Fmt.(styled `Bold string) "More changes available"
1323
+
changes_result.new_state;
1324
+
1325
+
Fmt.pr "@]@."
1326
+
in
1327
+
let doc = "Incremental sync (demonstrates changes + get chain)" in
1328
+
let info = Cmd.info "sync" ~doc in
1329
+
Cmd.v info Term.(const run $ Jmap_eio.Cli.config_term $ state_term)
1330
+
1331
+
(** Headers command - demonstrates RFC 8621 ยง4.1 header property queries *)
1332
+
let headers_cmd =
1333
+
let email_id_term =
1334
+
let doc = "Email ID to get headers for" in
1335
+
Arg.(required & pos 0 (some string) None & info [] ~docv:"EMAIL_ID" ~doc)
1336
+
in
1337
+
1338
+
(* Format a header value for display *)
1339
+
let format_header_value = function
1340
+
| Jmap.Proto.Email_header.String_single None -> "(null)"
1341
+
| Jmap.Proto.Email_header.String_single (Some s) -> s
1342
+
| Jmap.Proto.Email_header.String_all [] -> "(empty list)"
1343
+
| Jmap.Proto.Email_header.String_all strs -> String.concat "; " strs
1344
+
| Jmap.Proto.Email_header.Addresses_single None -> "(null)"
1345
+
| Jmap.Proto.Email_header.Addresses_single (Some []) -> "(empty)"
1346
+
| Jmap.Proto.Email_header.Addresses_single (Some addrs) ->
1347
+
String.concat ", " (List.map (fun a ->
1348
+
match a.Jmap.Proto.Email_address.name with
1349
+
| Some n -> Printf.sprintf "%s <%s>" n a.email
1350
+
| None -> a.email
1351
+
) addrs)
1352
+
| Jmap.Proto.Email_header.Addresses_all [] -> "(empty list)"
1353
+
| Jmap.Proto.Email_header.Addresses_all groups ->
1354
+
String.concat " | " (List.map (fun addrs ->
1355
+
String.concat ", " (List.map (fun a ->
1356
+
match a.Jmap.Proto.Email_address.name with
1357
+
| Some n -> Printf.sprintf "%s <%s>" n a.email
1358
+
| None -> a.email
1359
+
) addrs)
1360
+
) groups)
1361
+
| Jmap.Proto.Email_header.Grouped_single None -> "(null)"
1362
+
| Jmap.Proto.Email_header.Grouped_single (Some groups) ->
1363
+
String.concat "; " (List.map (fun g ->
1364
+
let name = Option.value ~default:"(ungrouped)" g.Jmap.Proto.Email_address.Group.name in
1365
+
let addrs = String.concat ", " (List.map (fun a ->
1366
+
match a.Jmap.Proto.Email_address.name with
1367
+
| Some n -> Printf.sprintf "%s <%s>" n a.email
1368
+
| None -> a.email
1369
+
) g.addresses) in
1370
+
Printf.sprintf "%s: %s" name addrs
1371
+
) groups)
1372
+
| Jmap.Proto.Email_header.Grouped_all _ -> "(grouped addresses list)"
1373
+
| Jmap.Proto.Email_header.Date_single None -> "(null)"
1374
+
| Jmap.Proto.Email_header.Date_single (Some t) -> ptime_to_string t
1375
+
| Jmap.Proto.Email_header.Date_all [] -> "(empty list)"
1376
+
| Jmap.Proto.Email_header.Date_all dates ->
1377
+
String.concat "; " (List.map (function
1378
+
| None -> "(null)"
1379
+
| Some t -> ptime_to_string t
1380
+
) dates)
1381
+
| Jmap.Proto.Email_header.Strings_single None -> "(null)"
1382
+
| Jmap.Proto.Email_header.Strings_single (Some []) -> "(empty)"
1383
+
| Jmap.Proto.Email_header.Strings_single (Some strs) -> String.concat ", " strs
1384
+
| Jmap.Proto.Email_header.Strings_all [] -> "(empty list)"
1385
+
| Jmap.Proto.Email_header.Strings_all groups ->
1386
+
String.concat " | " (List.map (function
1387
+
| None -> "(null)"
1388
+
| Some strs -> String.concat ", " strs
1389
+
) groups)
1390
+
in
1391
+
1392
+
let run cfg email_id_str =
1393
+
Eio_main.run @@ fun env ->
1394
+
Eio.Switch.run @@ fun sw ->
1395
+
let client = Jmap_eio.Cli.create_client ~sw env cfg in
1396
+
let account_id = Jmap_eio.Cli.get_account_id cfg client in
1397
+
let target_email_id = Jmap.Proto.Id.of_string_exn email_id_str in
1398
+
1399
+
Jmap_eio.Cli.debug cfg "Fetching headers for email %s" email_id_str;
1400
+
1401
+
(* Demonstrate various header forms from RFC 8621 ยง4.1.2:
1402
+
- header:name - Raw value
1403
+
- header:name:asText - Text decoded
1404
+
- header:name:asAddresses - Address list
1405
+
- header:name:asGroupedAddresses - Address groups
1406
+
- header:name:asMessageIds - Message-ID list
1407
+
- header:name:asDate - RFC 3339 date
1408
+
- header:name:asURLs - URL list
1409
+
- header:name:all - All values (not just first)
1410
+
*)
1411
+
let header_props = [
1412
+
(* Raw and text forms *)
1413
+
"header:Subject";
1414
+
"header:Subject:asText";
1415
+
(* Address headers *)
1416
+
"header:From:asAddresses";
1417
+
"header:To:asAddresses";
1418
+
"header:Cc:asAddresses";
1419
+
"header:Bcc:asAddresses";
1420
+
"header:Reply-To:asAddresses";
1421
+
"header:Sender:asAddresses";
1422
+
(* Grouped addresses *)
1423
+
"header:From:asGroupedAddresses";
1424
+
(* Message ID headers *)
1425
+
"header:Message-ID:asMessageIds";
1426
+
"header:In-Reply-To:asMessageIds";
1427
+
"header:References:asMessageIds";
1428
+
(* Date header *)
1429
+
"header:Date:asDate";
1430
+
(* List headers as URLs *)
1431
+
"header:List-Unsubscribe:asURLs";
1432
+
"header:List-Post:asURLs";
1433
+
"header:List-Archive:asURLs";
1434
+
(* Custom headers *)
1435
+
"header:X-Mailer:asText";
1436
+
"header:X-Priority";
1437
+
"header:X-Spam-Status:asText";
1438
+
"header:Content-Type";
1439
+
"header:MIME-Version";
1440
+
(* Get all Received headers (typically multiple) *)
1441
+
"header:Received:all";
1442
+
] in
1443
+
1444
+
let properties = "id" :: "threadId" :: "subject" :: header_props in
1445
+
1446
+
let get_inv = Jmap_eio.Client.Build.email_get
1447
+
~call_id:"h1"
1448
+
~account_id
1449
+
~ids:[target_email_id]
1450
+
~properties
1451
+
()
1452
+
in
1453
+
let req = Jmap_eio.Client.Build.(
1454
+
make_request
1455
+
~capabilities:[Jmap.Proto.Capability.core; Jmap.Proto.Capability.mail]
1456
+
[get_inv]
1457
+
) in
1458
+
1459
+
match Jmap_eio.Client.request client req with
1460
+
| Error e ->
1461
+
Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e);
1462
+
exit 1
1463
+
| Ok response ->
1464
+
match Jmap_eio.Client.Parse.parse_email_get ~call_id:"h1" response with
1465
+
| Error e ->
1466
+
Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e);
1467
+
exit 1
1468
+
| Ok email_result ->
1469
+
match email_result.list with
1470
+
| [] ->
1471
+
Fmt.epr "Email not found: %s@." email_id_str;
1472
+
exit 1
1473
+
| email :: _ ->
1474
+
Fmt.pr "@[<v>%a@," Fmt.(styled `Bold string) "Email Headers (RFC 8621 ยง4.1)";
1475
+
Fmt.pr "ID: %s@," (email_id email);
1476
+
Fmt.pr "Thread: %s@," (email_thread_id email);
1477
+
(match email.subject with
1478
+
| Some s -> Fmt.pr "Subject (convenience): %s@," s
1479
+
| None -> ());
1480
+
Fmt.pr "@,";
1481
+
1482
+
(* Print dynamic headers grouped by category *)
1483
+
let raw_headers = Jmap.Proto.Email.dynamic_headers_raw email in
1484
+
if raw_headers = [] then
1485
+
Fmt.pr "%a@," Fmt.(styled `Yellow string) "No dynamic headers returned"
1486
+
else begin
1487
+
Fmt.pr "%a (%d properties)@,@,"
1488
+
Fmt.(styled `Bold string) "Dynamic Header Properties"
1489
+
(List.length raw_headers);
1490
+
1491
+
List.iter (fun (name, json) ->
1492
+
match Jmap.Proto.Email.decode_header_value name json with
1493
+
| None ->
1494
+
Fmt.pr " %a: (decode failed)@,"
1495
+
Fmt.(styled `Red string) name
1496
+
| Some value ->
1497
+
let formatted = format_header_value value in
1498
+
if String.length formatted > 80 then
1499
+
Fmt.pr " %a:@, %s@,"
1500
+
Fmt.(styled `Cyan string) name
1501
+
formatted
1502
+
else
1503
+
Fmt.pr " %a: %s@,"
1504
+
Fmt.(styled `Cyan string) name
1505
+
formatted
1506
+
) raw_headers
1507
+
end;
1508
+
Fmt.pr "@]@."
1509
+
in
1510
+
let doc = "Show email headers in various forms (demonstrates RFC 8621 ยง4.1)" in
1511
+
let info = Cmd.info "headers" ~doc in
1512
+
Cmd.v info Term.(const run $ Jmap_eio.Cli.config_term $ email_id_term)
1513
+
719
1514
(** {1 Main Command Group} *)
720
1515
721
1516
let main_cmd =
···
742
1537
recent_cmd;
743
1538
threads_cmd;
744
1539
identities_cmd;
1540
+
headers_cmd;
1541
+
(* Chain API examples *)
1542
+
inbox_cmd;
1543
+
thread_view_cmd;
1544
+
mark_read_cmd;
1545
+
delete_email_cmd;
1546
+
changes_cmd;
1547
+
sync_cmd;
745
1548
]
746
1549
747
1550
let () =
+662
bin/jmapq.ml
+662
bin/jmapq.ml
···
1
+
(*---------------------------------------------------------------------------
2
+
Copyright (c) 2025 Anil Madhavapeddy. All rights reserved.
3
+
SPDX-License-Identifier: ISC
4
+
---------------------------------------------------------------------------*)
5
+
6
+
(** JMAPQ - Specialist JMAP workflow commands *)
7
+
8
+
open Cmdliner
9
+
10
+
(** {1 Helpers} *)
11
+
12
+
let ptime_to_string t =
13
+
let (y, m, d), ((hh, mm, ss), _tz) = Ptime.to_date_time t in
14
+
Printf.sprintf "%04d-%02d-%02d %02d:%02d:%02d" y m d hh mm ss
15
+
16
+
let truncate_string max_len s =
17
+
if String.length s <= max_len then s
18
+
else String.sub s 0 (max_len - 3) ^ "..."
19
+
20
+
(** {1 Zulip Types and Codec} *)
21
+
22
+
(** Parsed information from a Zulip notification email subject.
23
+
Subject format: "#Channel > topic [Server Name]" *)
24
+
module Zulip_message = struct
25
+
type t = {
26
+
id : string;
27
+
date : Ptime.t;
28
+
thread_id : string;
29
+
channel : string;
30
+
topic : string;
31
+
server : string;
32
+
is_read : bool;
33
+
labels : string list;
34
+
}
35
+
36
+
(** Parse a Zulip subject line of the form "#Channel > topic [Server Name]" *)
37
+
let parse_subject subject =
38
+
(* Pattern: #<channel> > <topic> [<server>] *)
39
+
let channel_re = Re.Pcre.regexp {|^#(.+?)\s*>\s*(.+?)\s*\[(.+?)\]$|} in
40
+
match Re.exec_opt channel_re subject with
41
+
| Some groups ->
42
+
let channel = Re.Group.get groups 1 in
43
+
let topic = Re.Group.get groups 2 in
44
+
let server = Re.Group.get groups 3 in
45
+
Some (channel, topic, server)
46
+
| None -> None
47
+
48
+
(** Check if an email has the $seen keyword *)
49
+
let is_seen keywords =
50
+
List.exists (fun (k, v) -> k = "$seen" && v) keywords
51
+
52
+
(** Extract label strings from keywords, excluding standard JMAP keywords *)
53
+
let extract_labels keywords =
54
+
keywords
55
+
|> List.filter_map (fun (k, v) ->
56
+
if v && not (String.length k > 0 && k.[0] = '$') then
57
+
Some k
58
+
else if v && k = "$flagged" then
59
+
Some "flagged"
60
+
else
61
+
None)
62
+
63
+
(** Create a Zulip_message from a JMAP Email *)
64
+
let of_email (email : Jmap.Proto.Email.t) : t option =
65
+
let id = match email.id with
66
+
| Some id -> Jmap.Proto.Id.to_string id
67
+
| None -> ""
68
+
in
69
+
let date = match email.received_at with
70
+
| Some t -> t
71
+
| None -> Ptime.epoch
72
+
in
73
+
let thread_id = match email.thread_id with
74
+
| Some id -> Jmap.Proto.Id.to_string id
75
+
| None -> ""
76
+
in
77
+
let subject = Option.value ~default:"" email.subject in
78
+
match parse_subject subject with
79
+
| None -> None
80
+
| Some (channel, topic, server) ->
81
+
let keywords = Option.value ~default:[] email.keywords in
82
+
let is_read = is_seen keywords in
83
+
let labels = extract_labels keywords in
84
+
Some { id; date; thread_id; channel; topic; server; is_read; labels }
85
+
86
+
(** Jsont codec for Ptime.t - reuse the library's UTC date codec *)
87
+
let ptime_jsont : Ptime.t Jsont.t = Jmap.Proto.Date.Utc.jsont
88
+
89
+
(** Jsont codec for a single Zulip message *)
90
+
let jsont : t Jsont.t =
91
+
let kind = "ZulipMessage" in
92
+
let make id date thread_id channel topic server is_read labels =
93
+
{ id; date; thread_id; channel; topic; server; is_read; labels }
94
+
in
95
+
Jsont.Object.map ~kind make
96
+
|> Jsont.Object.mem "id" Jsont.string ~enc:(fun t -> t.id)
97
+
|> Jsont.Object.mem "date" ptime_jsont ~enc:(fun t -> t.date)
98
+
|> Jsont.Object.mem "thread_id" Jsont.string ~enc:(fun t -> t.thread_id)
99
+
|> Jsont.Object.mem "channel" Jsont.string ~enc:(fun t -> t.channel)
100
+
|> Jsont.Object.mem "topic" Jsont.string ~enc:(fun t -> t.topic)
101
+
|> Jsont.Object.mem "server" Jsont.string ~enc:(fun t -> t.server)
102
+
|> Jsont.Object.mem "is_read" Jsont.bool ~enc:(fun t -> t.is_read)
103
+
|> Jsont.Object.mem "labels" (Jsont.list Jsont.string) ~enc:(fun t -> t.labels)
104
+
|> Jsont.Object.finish
105
+
106
+
(** Jsont codec for a list of Zulip messages *)
107
+
let list_jsont : t list Jsont.t = Jsont.list jsont
108
+
end
109
+
110
+
(** {1 Zulip List Command} *)
111
+
112
+
let zulip_list_cmd =
113
+
let json_term =
114
+
let doc = "Output as JSON" in
115
+
Arg.(value & flag & info ["json"] ~doc)
116
+
in
117
+
let limit_term =
118
+
let doc = "Maximum number of messages to fetch (default: all)" in
119
+
Arg.(value & opt (some int) None & info ["limit"; "n"] ~docv:"N" ~doc)
120
+
in
121
+
let run cfg json_output limit =
122
+
Eio_main.run @@ fun env ->
123
+
Eio.Switch.run @@ fun sw ->
124
+
let client = Jmap_eio.Cli.create_client ~sw env cfg in
125
+
let account_id = Jmap_eio.Cli.get_account_id cfg client in
126
+
127
+
Jmap_eio.Cli.debug cfg "Searching for Zulip notification emails";
128
+
129
+
(* Build filter for emails from noreply@zulip.com *)
130
+
let cond : Jmap.Proto.Email.Filter_condition.t = {
131
+
in_mailbox = None; in_mailbox_other_than = None;
132
+
before = None; after = None;
133
+
min_size = None; max_size = None;
134
+
all_in_thread_have_keyword = None;
135
+
some_in_thread_have_keyword = None;
136
+
none_in_thread_have_keyword = None;
137
+
has_keyword = None; not_keyword = None;
138
+
has_attachment = None;
139
+
text = None;
140
+
from = Some "noreply@zulip.com";
141
+
to_ = None;
142
+
cc = None; bcc = None; subject = None;
143
+
body = None; header = None;
144
+
} in
145
+
let filter = Jmap.Proto.Filter.Condition cond in
146
+
let sort = [Jmap.Proto.Filter.comparator ~is_ascending:false "receivedAt"] in
147
+
148
+
(* Query for all Zulip emails *)
149
+
let query_limit = match limit with
150
+
| Some n -> Int64.of_int n
151
+
| None -> Int64.of_int 10000 (* Large default to get "all" *)
152
+
in
153
+
let query_inv = Jmap_eio.Client.Build.email_query
154
+
~call_id:"q1"
155
+
~account_id
156
+
~filter
157
+
~sort
158
+
~limit:query_limit
159
+
()
160
+
in
161
+
162
+
let req = Jmap_eio.Client.Build.(
163
+
make_request
164
+
~capabilities:[Jmap.Proto.Capability.core; Jmap.Proto.Capability.mail]
165
+
[query_inv]
166
+
) in
167
+
168
+
match Jmap_eio.Client.request client req with
169
+
| Error e ->
170
+
Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e);
171
+
exit 1
172
+
| Ok response ->
173
+
match Jmap_eio.Client.Parse.parse_email_query ~call_id:"q1" response with
174
+
| Error e ->
175
+
Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e);
176
+
exit 1
177
+
| Ok query_result ->
178
+
let email_ids = query_result.ids in
179
+
Jmap_eio.Cli.debug cfg "Found %d Zulip email IDs" (List.length email_ids);
180
+
181
+
if List.length email_ids = 0 then (
182
+
if json_output then
183
+
Fmt.pr "[]@."
184
+
else
185
+
Fmt.pr "No Zulip notification emails found.@."
186
+
) else (
187
+
(* Fetch email details *)
188
+
let get_inv = Jmap_eio.Client.Build.email_get
189
+
~call_id:"g1"
190
+
~account_id
191
+
~ids:email_ids
192
+
~properties:["id"; "blobId"; "threadId"; "mailboxIds"; "keywords";
193
+
"size"; "receivedAt"; "subject"; "from"]
194
+
()
195
+
in
196
+
let req2 = Jmap_eio.Client.Build.(
197
+
make_request
198
+
~capabilities:[Jmap.Proto.Capability.core; Jmap.Proto.Capability.mail]
199
+
[get_inv]
200
+
) in
201
+
202
+
match Jmap_eio.Client.request client req2 with
203
+
| Error e ->
204
+
Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e);
205
+
exit 1
206
+
| Ok response2 ->
207
+
match Jmap_eio.Client.Parse.parse_email_get ~call_id:"g1" response2 with
208
+
| Error e ->
209
+
Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e);
210
+
exit 1
211
+
| Ok get_result ->
212
+
(* Parse Zulip subjects and filter successful parses *)
213
+
let zulip_messages =
214
+
get_result.list
215
+
|> List.filter_map Zulip_message.of_email
216
+
in
217
+
218
+
Jmap_eio.Cli.debug cfg "Parsed %d Zulip messages from %d emails"
219
+
(List.length zulip_messages)
220
+
(List.length get_result.list);
221
+
222
+
if json_output then (
223
+
(* Output as JSON *)
224
+
match Jsont_bytesrw.encode_string' ~format:Jsont.Indent Zulip_message.list_jsont zulip_messages with
225
+
| Ok json_str -> Fmt.pr "%s@." json_str
226
+
| Error e -> Fmt.epr "JSON encoding error: %s@." (Jsont.Error.to_string e)
227
+
) else (
228
+
(* Human-readable output *)
229
+
Fmt.pr "@[<v>%a (%d messages)@,@,"
230
+
Fmt.(styled `Bold string) "Zulip Notifications"
231
+
(List.length zulip_messages);
232
+
233
+
(* Group by server, then by channel *)
234
+
let by_server = Hashtbl.create 8 in
235
+
List.iter (fun (msg : Zulip_message.t) ->
236
+
let existing = try Hashtbl.find by_server msg.server with Not_found -> [] in
237
+
Hashtbl.replace by_server msg.server (msg :: existing)
238
+
) zulip_messages;
239
+
240
+
Hashtbl.iter (fun server msgs ->
241
+
Fmt.pr "%a [%s]@,"
242
+
Fmt.(styled `Bold string) "Server:"
243
+
server;
244
+
245
+
(* Group by channel within server *)
246
+
let by_channel = Hashtbl.create 8 in
247
+
List.iter (fun (msg : Zulip_message.t) ->
248
+
let existing = try Hashtbl.find by_channel msg.channel with Not_found -> [] in
249
+
Hashtbl.replace by_channel msg.channel (msg :: existing)
250
+
) msgs;
251
+
252
+
Hashtbl.iter (fun channel channel_msgs ->
253
+
Fmt.pr " %a #%s (%d)@,"
254
+
Fmt.(styled `Cyan string) "Channel:"
255
+
channel
256
+
(List.length channel_msgs);
257
+
258
+
(* Sort by date descending *)
259
+
let sorted = List.sort (fun a b ->
260
+
Ptime.compare b.Zulip_message.date a.Zulip_message.date
261
+
) channel_msgs in
262
+
263
+
List.iter (fun (msg : Zulip_message.t) ->
264
+
let read_marker = if msg.is_read then " " else "*" in
265
+
let labels_str = match msg.labels with
266
+
| [] -> ""
267
+
| ls -> " [" ^ String.concat ", " ls ^ "]"
268
+
in
269
+
Fmt.pr " %s %s %a %s%s@,"
270
+
read_marker
271
+
(ptime_to_string msg.date)
272
+
Fmt.(styled `Yellow string) (truncate_string 40 msg.topic)
273
+
(truncate_string 12 msg.id)
274
+
labels_str
275
+
) sorted;
276
+
Fmt.pr "@,"
277
+
) by_channel
278
+
) by_server;
279
+
280
+
Fmt.pr "@]@."
281
+
)
282
+
)
283
+
in
284
+
let doc = "List Zulip notification emails with parsed channel/topic info" in
285
+
let man = [
286
+
`S Manpage.s_description;
287
+
`P "Lists all emails from noreply@zulip.com and parses the subject line to extract \
288
+
the Zulip channel, topic, and server name.";
289
+
`P "Subject format expected: \"#Channel > topic [Server Name]\"";
290
+
`S Manpage.s_examples;
291
+
`P "List all Zulip notifications:";
292
+
`Pre " jmapq zulip-list";
293
+
`P "Output as JSON:";
294
+
`Pre " jmapq zulip-list --json";
295
+
`P "Limit to 50 most recent:";
296
+
`Pre " jmapq zulip-list -n 50";
297
+
] in
298
+
let info = Cmd.info "zulip-list" ~doc ~man in
299
+
Cmd.v info Term.(const run $ Jmap_eio.Cli.config_term $ json_term $ limit_term)
300
+
301
+
(** {1 Zulip Timeout Command} *)
302
+
303
+
(** The keyword used to mark Zulip notifications as processed *)
304
+
let zulip_processed_keyword = "zulip-processed"
305
+
306
+
let zulip_timeout_cmd =
307
+
let email_ids_term =
308
+
let doc = "Email IDs to mark as processed" in
309
+
Arg.(non_empty & pos_all string [] & info [] ~docv:"EMAIL_ID" ~doc)
310
+
in
311
+
let verbose_term =
312
+
let doc = "Show the raw JMAP server response" in
313
+
Arg.(value & flag & info ["v"; "verbose"] ~doc)
314
+
in
315
+
let run cfg verbose email_id_strs =
316
+
Eio_main.run @@ fun env ->
317
+
Eio.Switch.run @@ fun sw ->
318
+
let client = Jmap_eio.Cli.create_client ~sw env cfg in
319
+
let account_id = Jmap_eio.Cli.get_account_id cfg client in
320
+
let email_ids = List.map Jmap.Proto.Id.of_string_exn email_id_strs in
321
+
322
+
Jmap_eio.Cli.debug cfg "Marking %d email(s) with '%s' keyword"
323
+
(List.length email_ids) zulip_processed_keyword;
324
+
325
+
(* Build patch to add the zulip-processed keyword and mark as read *)
326
+
let patch =
327
+
let open Jmap_eio.Chain in
328
+
json_obj [
329
+
("keywords/" ^ zulip_processed_keyword, json_bool true);
330
+
("keywords/$seen", json_bool true);
331
+
]
332
+
in
333
+
334
+
(* Build updates list: each email ID gets the same patch *)
335
+
let updates = List.map (fun id -> (id, patch)) email_ids in
336
+
337
+
let open Jmap_eio.Chain in
338
+
let request, set_h = build
339
+
~capabilities:[Jmap.Proto.Capability.core; Jmap.Proto.Capability.mail]
340
+
begin
341
+
email_set ~account_id
342
+
~update:updates
343
+
()
344
+
end in
345
+
346
+
match Jmap_eio.Client.request client request with
347
+
| Error e ->
348
+
Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e);
349
+
exit 1
350
+
| Ok response ->
351
+
(* Print raw response if verbose *)
352
+
if verbose then begin
353
+
Fmt.pr "@[<v>%a:@," Fmt.(styled `Bold string) "Server Response";
354
+
(match Jsont_bytesrw.encode_string' ~format:Jsont.Indent
355
+
Jmap.Proto.Response.jsont response with
356
+
| Ok json_str -> Fmt.pr "%s@,@]@." json_str
357
+
| Error e -> Fmt.epr "JSON encoding error: %s@." (Jsont.Error.to_string e))
358
+
end;
359
+
(* Check for JMAP method-level errors first *)
360
+
let call_id = Jmap_eio.Chain.call_id set_h in
361
+
(match Jmap.Proto.Response.find_response call_id response with
362
+
| None ->
363
+
Fmt.epr "Error: No response found for call_id %s@." call_id;
364
+
exit 1
365
+
| Some inv when Jmap.Proto.Response.is_error inv ->
366
+
(match Jmap.Proto.Response.get_error inv with
367
+
| Some err ->
368
+
Fmt.epr "JMAP Error: %s%s@."
369
+
(Jmap.Proto.Error.method_error_type_to_string err.type_)
370
+
(match err.description with Some d -> " - " ^ d | None -> "");
371
+
exit 1
372
+
| None ->
373
+
Fmt.epr "JMAP Error: Unknown error@.";
374
+
exit 1)
375
+
| Some _ ->
376
+
match parse set_h response with
377
+
| Error e ->
378
+
Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e);
379
+
exit 1
380
+
| Ok result ->
381
+
(* Report successes *)
382
+
let updated_ids =
383
+
result.updated
384
+
|> Option.value ~default:[]
385
+
|> List.map (fun (id, _) -> Jmap.Proto.Id.to_string id)
386
+
in
387
+
if List.length updated_ids > 0 then begin
388
+
Fmt.pr "@[<v>%a %d email(s) as read with '%s':@,"
389
+
Fmt.(styled `Green string) "Marked"
390
+
(List.length updated_ids)
391
+
zulip_processed_keyword;
392
+
List.iter (fun id -> Fmt.pr " %s@," id) updated_ids;
393
+
Fmt.pr "@]@."
394
+
end;
395
+
396
+
(* Report failures *)
397
+
let not_updated = Option.value ~default:[] result.not_updated in
398
+
if not_updated <> [] then begin
399
+
Fmt.epr "@[<v>%a to mark %d email(s):@,"
400
+
Fmt.(styled `Red string) "Failed"
401
+
(List.length not_updated);
402
+
List.iter (fun (id, err) ->
403
+
let open Jmap.Proto.Error in
404
+
let err_type = set_error_type_to_string err.type_ in
405
+
let err_desc = Option.value ~default:"" err.description in
406
+
Fmt.epr " %s: %s%s@,"
407
+
(Jmap.Proto.Id.to_string id)
408
+
err_type
409
+
(if err_desc = "" then "" else " - " ^ err_desc)
410
+
) not_updated;
411
+
Fmt.epr "@]@.";
412
+
exit 1
413
+
end)
414
+
in
415
+
let doc = "Mark Zulip notification emails as processed" in
416
+
let man = [
417
+
`S Manpage.s_description;
418
+
`P (Printf.sprintf "Adds the '%s' keyword to the specified email(s). \
419
+
This keyword can be used to filter processed Zulip notifications \
420
+
or set up server-side rules to auto-archive them."
421
+
zulip_processed_keyword);
422
+
`S Manpage.s_examples;
423
+
`P "Mark a single email as processed:";
424
+
`Pre " jmapq zulip-timeout StrrDTS_WEa3";
425
+
`P "Mark multiple emails as processed:";
426
+
`Pre " jmapq zulip-timeout StrrDTS_WEa3 StrsGZ7P8Dpc StrsGuCSXJ3Z";
427
+
] in
428
+
let info = Cmd.info "zulip-timeout" ~doc ~man in
429
+
Cmd.v info Term.(const run $ Jmap_eio.Cli.config_term $ verbose_term $ email_ids_term)
430
+
431
+
(** {1 Zulip View Command} *)
432
+
433
+
let zulip_view_cmd =
434
+
let json_term =
435
+
let doc = "Output as JSON" in
436
+
Arg.(value & flag & info ["json"] ~doc)
437
+
in
438
+
let limit_term =
439
+
let doc = "Maximum number of messages to fetch (default: all)" in
440
+
Arg.(value & opt (some int) None & info ["limit"; "n"] ~docv:"N" ~doc)
441
+
in
442
+
let verbose_term =
443
+
let doc = "Show the raw JMAP request and response" in
444
+
Arg.(value & flag & info ["v"; "verbose"] ~doc)
445
+
in
446
+
let run cfg json_output limit verbose =
447
+
Eio_main.run @@ fun env ->
448
+
Eio.Switch.run @@ fun sw ->
449
+
let client = Jmap_eio.Cli.create_client ~sw env cfg in
450
+
let account_id = Jmap_eio.Cli.get_account_id cfg client in
451
+
452
+
Jmap_eio.Cli.debug cfg "Searching for Zulip emails marked as processed";
453
+
454
+
(* Build filter for emails from noreply@zulip.com with zulip-processed keyword *)
455
+
let cond : Jmap.Proto.Email.Filter_condition.t = {
456
+
in_mailbox = None; in_mailbox_other_than = None;
457
+
before = None; after = None;
458
+
min_size = None; max_size = None;
459
+
all_in_thread_have_keyword = None;
460
+
some_in_thread_have_keyword = None;
461
+
none_in_thread_have_keyword = None;
462
+
has_keyword = Some zulip_processed_keyword;
463
+
not_keyword = None;
464
+
has_attachment = None;
465
+
text = None;
466
+
from = Some "noreply@zulip.com";
467
+
to_ = None;
468
+
cc = None; bcc = None; subject = None;
469
+
body = None; header = None;
470
+
} in
471
+
let filter = Jmap.Proto.Filter.Condition cond in
472
+
let sort = [Jmap.Proto.Filter.comparator ~is_ascending:false "receivedAt"] in
473
+
474
+
(* Query for processed Zulip emails *)
475
+
let query_limit = match limit with
476
+
| Some n -> Int64.of_int n
477
+
| None -> Int64.of_int 10000
478
+
in
479
+
let query_inv = Jmap_eio.Client.Build.email_query
480
+
~call_id:"q1"
481
+
~account_id
482
+
~filter
483
+
~sort
484
+
~limit:query_limit
485
+
()
486
+
in
487
+
488
+
let req = Jmap_eio.Client.Build.(
489
+
make_request
490
+
~capabilities:[Jmap.Proto.Capability.core; Jmap.Proto.Capability.mail]
491
+
[query_inv]
492
+
) in
493
+
494
+
(* Print request if verbose *)
495
+
if verbose then begin
496
+
Fmt.pr "@[<v>%a:@," Fmt.(styled `Bold string) "Request";
497
+
(match Jsont_bytesrw.encode_string' ~format:Jsont.Indent
498
+
Jmap.Proto.Request.jsont req with
499
+
| Ok json_str -> Fmt.pr "%s@,@]@." json_str
500
+
| Error e -> Fmt.epr "JSON encoding error: %s@." (Jsont.Error.to_string e))
501
+
end;
502
+
503
+
match Jmap_eio.Client.request client req with
504
+
| Error e ->
505
+
Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e);
506
+
exit 1
507
+
| Ok response ->
508
+
(* Print response if verbose *)
509
+
if verbose then begin
510
+
Fmt.pr "@[<v>%a:@," Fmt.(styled `Bold string) "Response";
511
+
(match Jsont_bytesrw.encode_string' ~format:Jsont.Indent
512
+
Jmap.Proto.Response.jsont response with
513
+
| Ok json_str -> Fmt.pr "%s@,@]@." json_str
514
+
| Error e -> Fmt.epr "JSON encoding error: %s@." (Jsont.Error.to_string e))
515
+
end;
516
+
match Jmap_eio.Client.Parse.parse_email_query ~call_id:"q1" response with
517
+
| Error e ->
518
+
Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e);
519
+
exit 1
520
+
| Ok query_result ->
521
+
let email_ids = query_result.ids in
522
+
Jmap_eio.Cli.debug cfg "Found %d processed Zulip email IDs" (List.length email_ids);
523
+
524
+
if List.length email_ids = 0 then (
525
+
if json_output then
526
+
Fmt.pr "[]@."
527
+
else
528
+
Fmt.pr "No Zulip emails marked as processed.@."
529
+
) else (
530
+
(* Fetch email details *)
531
+
let get_inv = Jmap_eio.Client.Build.email_get
532
+
~call_id:"g1"
533
+
~account_id
534
+
~ids:email_ids
535
+
~properties:["id"; "blobId"; "threadId"; "mailboxIds"; "keywords";
536
+
"size"; "receivedAt"; "subject"; "from"]
537
+
()
538
+
in
539
+
let req2 = Jmap_eio.Client.Build.(
540
+
make_request
541
+
~capabilities:[Jmap.Proto.Capability.core; Jmap.Proto.Capability.mail]
542
+
[get_inv]
543
+
) in
544
+
545
+
match Jmap_eio.Client.request client req2 with
546
+
| Error e ->
547
+
Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e);
548
+
exit 1
549
+
| Ok response2 ->
550
+
match Jmap_eio.Client.Parse.parse_email_get ~call_id:"g1" response2 with
551
+
| Error e ->
552
+
Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e);
553
+
exit 1
554
+
| Ok get_result ->
555
+
(* Parse Zulip subjects and filter successful parses *)
556
+
let zulip_messages =
557
+
get_result.list
558
+
|> List.filter_map Zulip_message.of_email
559
+
in
560
+
561
+
Jmap_eio.Cli.debug cfg "Parsed %d Zulip messages from %d emails"
562
+
(List.length zulip_messages)
563
+
(List.length get_result.list);
564
+
565
+
if json_output then (
566
+
(* Output as JSON *)
567
+
match Jsont_bytesrw.encode_string' ~format:Jsont.Indent Zulip_message.list_jsont zulip_messages with
568
+
| Ok json_str -> Fmt.pr "%s@." json_str
569
+
| Error e -> Fmt.epr "JSON encoding error: %s@." (Jsont.Error.to_string e)
570
+
) else (
571
+
(* Human-readable output *)
572
+
Fmt.pr "@[<v>%a (%d messages)@,@,"
573
+
Fmt.(styled `Bold string) "Processed Zulip Notifications"
574
+
(List.length zulip_messages);
575
+
576
+
(* Group by server, then by channel *)
577
+
let by_server = Hashtbl.create 8 in
578
+
List.iter (fun (msg : Zulip_message.t) ->
579
+
let existing = try Hashtbl.find by_server msg.server with Not_found -> [] in
580
+
Hashtbl.replace by_server msg.server (msg :: existing)
581
+
) zulip_messages;
582
+
583
+
Hashtbl.iter (fun server msgs ->
584
+
Fmt.pr "%a [%s]@,"
585
+
Fmt.(styled `Bold string) "Server:"
586
+
server;
587
+
588
+
(* Group by channel within server *)
589
+
let by_channel = Hashtbl.create 8 in
590
+
List.iter (fun (msg : Zulip_message.t) ->
591
+
let existing = try Hashtbl.find by_channel msg.channel with Not_found -> [] in
592
+
Hashtbl.replace by_channel msg.channel (msg :: existing)
593
+
) msgs;
594
+
595
+
Hashtbl.iter (fun channel channel_msgs ->
596
+
Fmt.pr " %a #%s (%d)@,"
597
+
Fmt.(styled `Cyan string) "Channel:"
598
+
channel
599
+
(List.length channel_msgs);
600
+
601
+
(* Sort by date descending *)
602
+
let sorted = List.sort (fun a b ->
603
+
Ptime.compare b.Zulip_message.date a.Zulip_message.date
604
+
) channel_msgs in
605
+
606
+
List.iter (fun (msg : Zulip_message.t) ->
607
+
let read_marker = if msg.is_read then " " else "*" in
608
+
let labels_str = match msg.labels with
609
+
| [] -> ""
610
+
| ls -> " [" ^ String.concat ", " ls ^ "]"
611
+
in
612
+
Fmt.pr " %s %s %a %s%s@,"
613
+
read_marker
614
+
(ptime_to_string msg.date)
615
+
Fmt.(styled `Yellow string) (truncate_string 40 msg.topic)
616
+
(truncate_string 12 msg.id)
617
+
labels_str
618
+
) sorted;
619
+
Fmt.pr "@,"
620
+
) by_channel
621
+
) by_server;
622
+
623
+
Fmt.pr "@]@."
624
+
)
625
+
)
626
+
in
627
+
let doc = "List Zulip emails that have been marked as processed" in
628
+
let man = [
629
+
`S Manpage.s_description;
630
+
`P (Printf.sprintf "Lists all Zulip notification emails that have the '%s' keyword."
631
+
zulip_processed_keyword);
632
+
`S Manpage.s_examples;
633
+
`P "List all processed Zulip notifications:";
634
+
`Pre " jmapq zulip-view";
635
+
`P "Output as JSON:";
636
+
`Pre " jmapq zulip-view --json";
637
+
`P "Limit to 50 most recent:";
638
+
`Pre " jmapq zulip-view -n 50";
639
+
] in
640
+
let info = Cmd.info "zulip-view" ~doc ~man in
641
+
Cmd.v info Term.(const run $ Jmap_eio.Cli.config_term $ json_term $ limit_term $ verbose_term)
642
+
643
+
(** {1 Main Command Group} *)
644
+
645
+
let main_cmd =
646
+
let doc = "JMAPQ - Specialist JMAP workflow commands" in
647
+
let man = [
648
+
`S Manpage.s_description;
649
+
`P "A collection of specialist workflow commands for JMAP email processing.";
650
+
`S Manpage.s_environment;
651
+
`P Jmap_eio.Cli.env_docs;
652
+
] in
653
+
let info = Cmd.info "jmapq" ~version:"0.1.0" ~doc ~man in
654
+
Cmd.group info [
655
+
zulip_list_cmd;
656
+
zulip_timeout_cmd;
657
+
zulip_view_cmd;
658
+
]
659
+
660
+
let () =
661
+
Fmt_tty.setup_std_outputs ();
662
+
exit (Cmd.eval main_cmd)
+7
doc/dune
+7
doc/dune
+13
doc/index.mld
+13
doc/index.mld
···
1
+
{0 jmap}
2
+
3
+
{!modules: Jmap Jmap_top}
4
+
5
+
{1 Tutorial}
6
+
7
+
See the {!page-tutorial} for a comprehensive guide to using JMAP with OCaml,
8
+
including how types map to JSON and practical examples.
9
+
10
+
{1 Browser Support}
11
+
12
+
For browser-based applications, see the [jmap-brr] package which provides
13
+
a JMAP client using the Brr library and js_of_ocaml.
+494
doc/tutorial.mld
+494
doc/tutorial.mld
···
1
+
{0 JMAP Tutorial}
2
+
3
+
This tutorial introduces JMAP (JSON Meta Application Protocol) and
4
+
demonstrates the [jmap] OCaml library through interactive examples. JMAP
5
+
is defined in {{:https://www.rfc-editor.org/rfc/rfc8620}RFC 8620} (core)
6
+
and {{:https://www.rfc-editor.org/rfc/rfc8621}RFC 8621} (mail).
7
+
8
+
{1 What is JMAP?}
9
+
10
+
JMAP is a modern, efficient protocol for synchronizing mail and other
11
+
data. It's designed as a better alternative to IMAP, addressing many of
12
+
IMAP's limitations:
13
+
14
+
{ul
15
+
{- {b Stateless over HTTP}: Unlike IMAP's persistent TCP connections, JMAP
16
+
uses standard HTTP POST requests with JSON payloads.}
17
+
{- {b Efficient batching}: Multiple operations can be combined into a single
18
+
request, reducing round-trips.}
19
+
{- {b Result references}: The output of one method call can be used as input
20
+
to another in the same request.}
21
+
{- {b Push support}: Built-in mechanisms for real-time notifications.}
22
+
{- {b Binary data handling}: Separate upload/download endpoints for large
23
+
attachments.}}
24
+
25
+
The core protocol (RFC 8620) defines the general structure, while RFC 8621
26
+
extends it specifically for email, mailboxes, threads, and related objects.
27
+
28
+
{1 Setup}
29
+
30
+
First, let's set up our environment. In the toplevel, load the library
31
+
with [#require "jmap.top";;] which will automatically install pretty
32
+
printers.
33
+
34
+
{@ocaml[
35
+
# Jmap_top.install ();;
36
+
- : unit = ()
37
+
# open Jmap;;
38
+
]}
39
+
40
+
For parsing and encoding JSON, we'll use some helper functions:
41
+
42
+
{@ocaml[
43
+
# let parse_json s =
44
+
match Jsont_bytesrw.decode_string Jsont.json s with
45
+
| Ok json -> json
46
+
| Error e -> failwith e;;
47
+
val parse_json : string -> Jsont.json = <fun>
48
+
# let json_to_string json =
49
+
match Jsont_bytesrw.encode_string ~format:Jsont.Indent Jsont.json json with
50
+
| Ok s -> s
51
+
| Error e -> failwith e;;
52
+
val json_to_string : Jsont.json -> string = <fun>
53
+
]}
54
+
55
+
{1 JMAP Identifiers}
56
+
57
+
From {{:https://www.rfc-editor.org/rfc/rfc8620#section-1.2}RFC 8620 Section 1.2}:
58
+
59
+
{i An "Id" is a String of at least 1 and a maximum of 255 octets in size,
60
+
and it MUST only contain characters from the "URL and Filename Safe"
61
+
base64 alphabet.}
62
+
63
+
The {!Jmap.Id} module provides type-safe identifiers:
64
+
65
+
{@ocaml[
66
+
# let id = Id.of_string_exn "abc123";;
67
+
val id : Id.t = abc123
68
+
# Id.to_string id;;
69
+
- : string = "abc123"
70
+
]}
71
+
72
+
Invalid identifiers are rejected:
73
+
74
+
{@ocaml[
75
+
# Id.of_string "";;
76
+
- : (Id.t, string) result = Error "Id cannot be empty"
77
+
# Id.of_string (String.make 256 'x');;
78
+
- : (Id.t, string) result = Error "Id cannot exceed 255 characters"
79
+
]}
80
+
81
+
{1 Keywords}
82
+
83
+
Email keywords are string flags that indicate message state. RFC 8621
84
+
defines standard keywords, and the library represents them as polymorphic
85
+
variants for type safety.
86
+
87
+
{2 Standard Keywords}
88
+
89
+
From {{:https://www.rfc-editor.org/rfc/rfc8621#section-4.1.1}RFC 8621
90
+
Section 4.1.1}:
91
+
92
+
{@ocaml[
93
+
# Keyword.of_string "$seen";;
94
+
- : Keyword.t = $seen
95
+
# Keyword.of_string "$flagged";;
96
+
- : Keyword.t = $flagged
97
+
# Keyword.of_string "$draft";;
98
+
- : Keyword.t = $draft
99
+
# Keyword.of_string "$answered";;
100
+
- : Keyword.t = $answered
101
+
]}
102
+
103
+
The standard keywords are:
104
+
105
+
{ul
106
+
{- [`Seen] - The email has been read}
107
+
{- [`Flagged] - The email has been flagged for attention}
108
+
{- [`Draft] - The email is a draft being composed}
109
+
{- [`Answered] - The email has been replied to}
110
+
{- [`Forwarded] - The email has been forwarded}
111
+
{- [`Phishing] - The email is likely phishing}
112
+
{- [`Junk] - The email is spam}
113
+
{- [`NotJunk] - The email is definitely not spam}}
114
+
115
+
{2 Extended Keywords}
116
+
117
+
The library also supports draft-ietf-mailmaint extended keywords:
118
+
119
+
{@ocaml[
120
+
# Keyword.of_string "$notify";;
121
+
- : Keyword.t = $notify
122
+
# Keyword.of_string "$muted";;
123
+
- : Keyword.t = $muted
124
+
# Keyword.of_string "$hasattachment";;
125
+
- : Keyword.t = $hasattachment
126
+
]}
127
+
128
+
{2 Custom Keywords}
129
+
130
+
Unknown keywords are preserved as [`Custom]:
131
+
132
+
{@ocaml[
133
+
# Keyword.of_string "$my_custom_flag";;
134
+
- : Keyword.t = $my_custom_flag
135
+
]}
136
+
137
+
{2 Converting Back to Strings}
138
+
139
+
{@ocaml[
140
+
# Keyword.to_string `Seen;;
141
+
- : string = "$seen"
142
+
# Keyword.to_string `Flagged;;
143
+
- : string = "$flagged"
144
+
# Keyword.to_string (`Custom "$important");;
145
+
- : string = "$important"
146
+
]}
147
+
148
+
{1 Mailbox Roles}
149
+
150
+
Mailboxes can have special roles that indicate their purpose. From
151
+
{{:https://www.rfc-editor.org/rfc/rfc8621#section-2}RFC 8621 Section 2}:
152
+
153
+
{@ocaml[
154
+
# Role.of_string "inbox";;
155
+
- : Role.t = inbox
156
+
# Role.of_string "sent";;
157
+
- : Role.t = sent
158
+
# Role.of_string "drafts";;
159
+
- : Role.t = drafts
160
+
# Role.of_string "trash";;
161
+
- : Role.t = trash
162
+
# Role.of_string "junk";;
163
+
- : Role.t = junk
164
+
# Role.of_string "archive";;
165
+
- : Role.t = archive
166
+
]}
167
+
168
+
Custom roles are also supported:
169
+
170
+
{@ocaml[
171
+
# Role.of_string "receipts";;
172
+
- : Role.t = receipts
173
+
]}
174
+
175
+
{1 Capabilities}
176
+
177
+
JMAP uses capability URIs to indicate supported features. From
178
+
{{:https://www.rfc-editor.org/rfc/rfc8620#section-2}RFC 8620 Section 2}:
179
+
180
+
{@ocaml[
181
+
# Capability.core_uri;;
182
+
- : string = "urn:ietf:params:jmap:core"
183
+
# Capability.mail_uri;;
184
+
- : string = "urn:ietf:params:jmap:mail"
185
+
# Capability.submission_uri;;
186
+
- : string = "urn:ietf:params:jmap:submission"
187
+
]}
188
+
189
+
{@ocaml[
190
+
# Capability.of_string Capability.core_uri;;
191
+
- : Capability.t = urn:ietf:params:jmap:core
192
+
# Capability.of_string Capability.mail_uri;;
193
+
- : Capability.t = urn:ietf:params:jmap:mail
194
+
# Capability.of_string "urn:example:custom";;
195
+
- : Capability.t = urn:example:custom
196
+
]}
197
+
198
+
{1 Understanding JMAP JSON Structure}
199
+
200
+
One of the key benefits of JMAP over IMAP is its use of JSON. Let's see
201
+
how OCaml types map to the wire format.
202
+
203
+
{2 Requests}
204
+
205
+
A JMAP request contains:
206
+
- [using]: List of capability URIs required
207
+
- [methodCalls]: Array of method invocations
208
+
209
+
Each method invocation is a triple: [methodName], [arguments], [callId].
210
+
211
+
Here's how a simple request is structured:
212
+
213
+
{x@ocaml[
214
+
# let req = Jmap.Proto.Request.create
215
+
~using:[Capability.core_uri; Capability.mail_uri]
216
+
~method_calls:[
217
+
Jmap.Proto.Invocation.create
218
+
~name:"Mailbox/get"
219
+
~arguments:(parse_json {|{"accountId": "abc123"}|})
220
+
~call_id:"c0"
221
+
]
222
+
();;
223
+
Line 7, characters 18-22:
224
+
Error: The function applied to this argument has type
225
+
method_call_id:string -> Proto.Invocation.t
226
+
This argument cannot be applied with label ~call_id
227
+
# Jmap_top.encode Jmap.Proto.Request.jsont req |> json_to_string |> print_endline;;
228
+
Line 1, characters 42-45:
229
+
Error: Unbound value req
230
+
Hint: Did you mean ref?
231
+
]x}
232
+
233
+
{2 Email Filter Conditions}
234
+
235
+
Filters demonstrate how complex query conditions map to JSON. From
236
+
{{:https://www.rfc-editor.org/rfc/rfc8621#section-4.4.1}RFC 8621
237
+
Section 4.4.1}:
238
+
239
+
{x@ocaml[
240
+
# let filter_condition : Jmap.Proto.Email.Filter_condition.t = {
241
+
in_mailbox = Some (Id.of_string_exn "inbox123");
242
+
in_mailbox_other_than = None;
243
+
before = None;
244
+
after = None;
245
+
min_size = None;
246
+
max_size = None;
247
+
all_in_thread_have_keyword = None;
248
+
some_in_thread_have_keyword = None;
249
+
none_in_thread_have_keyword = None;
250
+
has_keyword = Some "$flagged";
251
+
not_keyword = None;
252
+
has_attachment = Some true;
253
+
text = None;
254
+
from = Some "alice@";
255
+
to_ = None;
256
+
cc = None;
257
+
bcc = None;
258
+
subject = Some "urgent";
259
+
body = None;
260
+
header = None;
261
+
};;
262
+
Line 2, characters 23-52:
263
+
Error: This expression has type Id.t but an expression was expected of type
264
+
Proto.Id.t
265
+
# Jmap_top.encode Jmap.Proto.Email.Filter_condition.jsont filter_condition
266
+
|> json_to_string |> print_endline;;
267
+
Line 1, characters 57-73:
268
+
Error: Unbound value filter_condition
269
+
]x}
270
+
271
+
Notice how:
272
+
- OCaml record fields use [snake_case], but JSON uses [camelCase]
273
+
- [None] values are omitted from JSON (not sent as [null])
274
+
- The filter only includes non-empty conditions
275
+
276
+
{2 Filter Operators}
277
+
278
+
Filters can be combined with AND, OR, and NOT operators:
279
+
280
+
{x@ocaml[
281
+
# let combined_filter = Jmap.Proto.Filter.Operator {
282
+
operator = `And;
283
+
conditions = [
284
+
Condition filter_condition;
285
+
Condition { filter_condition with has_keyword = Some "$seen" }
286
+
]
287
+
};;
288
+
Line 4, characters 17-33:
289
+
Error: Unbound value filter_condition
290
+
]x}
291
+
292
+
{1 Method Chaining}
293
+
294
+
One of JMAP's most powerful features is result references - using the
295
+
output of one method as input to another. The {!Jmap.Chain} module
296
+
provides a monadic interface for building such requests.
297
+
298
+
From {{:https://www.rfc-editor.org/rfc/rfc8620#section-3.7}RFC 8620
299
+
Section 3.7}:
300
+
301
+
{i A method argument may use the result of a previous method invocation
302
+
in the same request.}
303
+
304
+
{2 Basic Example}
305
+
306
+
Query for emails, then fetch their details:
307
+
308
+
{[
309
+
open Jmap.Chain
310
+
311
+
let request, handle = build ~capabilities:[core; mail] begin
312
+
let* query = email_query ~account_id
313
+
~filter:(Condition { in_mailbox = Some inbox_id; (* ... *) })
314
+
~limit:50L ()
315
+
in
316
+
let* emails = email_get ~account_id
317
+
~ids:(from_query query) (* Reference query results! *)
318
+
~properties:["subject"; "from"; "receivedAt"]
319
+
()
320
+
in
321
+
return emails
322
+
end
323
+
][
324
+
{err@mdx-error[
325
+
Line 3, characters 46-50:
326
+
Error: Unbound value core
327
+
]err}]}
328
+
329
+
The key insight is [from_query query] - this creates a reference to the
330
+
[ids] array from the query response. The server processes both calls in
331
+
sequence, substituting the reference with actual IDs.
332
+
333
+
{2 Creation and Submission}
334
+
335
+
Create a draft and send it in one request:
336
+
337
+
{[
338
+
let* set_h, draft_cid = email_set ~account_id
339
+
~create:[("draft1", draft_email_json)]
340
+
()
341
+
in
342
+
let* _ = email_submission_set ~account_id
343
+
~create:[("sub1", submission_json
344
+
~email_id:(created_id_of_string "draft1") (* Reference creation! *)
345
+
~identity_id)]
346
+
()
347
+
in
348
+
return set_h
349
+
][
350
+
{err@mdx-error[
351
+
Line 1, characters 1-5:
352
+
Error: Unbound value ( let* )
353
+
]err}]}
354
+
355
+
{2 The RFC 8620 Example}
356
+
357
+
The RFC provides a complex example: fetch from/date/subject for all
358
+
emails in the first 10 threads in the inbox:
359
+
360
+
{[
361
+
let* q = email_query ~account_id
362
+
~filter:(Condition { in_mailbox = Some inbox_id; (* ... *) })
363
+
~sort:[comparator ~is_ascending:false "receivedAt"]
364
+
~collapse_threads:true ~limit:10L ()
365
+
in
366
+
let* e1 = email_get ~account_id
367
+
~ids:(from_query q)
368
+
~properties:["threadId"]
369
+
()
370
+
in
371
+
let* threads = thread_get ~account_id
372
+
~ids:(from_get_field e1 "threadId") (* Get threadIds from emails *)
373
+
()
374
+
in
375
+
let* e2 = email_get ~account_id
376
+
~ids:(from_get_field threads "emailIds") (* Get all emailIds in threads *)
377
+
~properties:["from"; "receivedAt"; "subject"]
378
+
()
379
+
in
380
+
return e2
381
+
][
382
+
{err@mdx-error[
383
+
Line 1, characters 1-5:
384
+
Error: Unbound value ( let* )
385
+
]err}]}
386
+
387
+
This entire flow executes in a {e single HTTP request}!
388
+
389
+
{1 Error Handling}
390
+
391
+
JMAP has a structured error system with three levels:
392
+
393
+
{2 Request-Level Errors}
394
+
395
+
These are returned with HTTP error status codes and RFC 7807 Problem
396
+
Details. From {{:https://www.rfc-editor.org/rfc/rfc8620#section-3.6.1}RFC
397
+
8620 Section 3.6.1}:
398
+
399
+
{@ocaml[
400
+
# Error.to_string (`Request {
401
+
Error.type_ = "urn:ietf:params:jmap:error:unknownCapability";
402
+
status = Some 400;
403
+
title = Some "Unknown Capability";
404
+
detail = Some "The server does not support 'urn:example:unsupported'";
405
+
limit = None;
406
+
});;
407
+
- : string =
408
+
"Request error: urn:ietf:params:jmap:error:unknownCapability (status 400): The server does not support 'urn:example:unsupported'"
409
+
]}
410
+
411
+
{2 Method-Level Errors}
412
+
413
+
Individual method calls can fail while others succeed:
414
+
415
+
{@ocaml[
416
+
# Error.to_string (`Method {
417
+
Error.type_ = "invalidArguments";
418
+
description = Some "The 'filter' argument is malformed";
419
+
});;
420
+
- : string =
421
+
"Method error: invalidArguments: The 'filter' argument is malformed"
422
+
]}
423
+
424
+
{2 SetError}
425
+
426
+
Object-level errors in /set responses:
427
+
428
+
{@ocaml[
429
+
# Error.to_string (`Set ("draft1", {
430
+
Error.type_ = "invalidProperties";
431
+
description = Some "Unknown property: foobar";
432
+
properties = Some ["foobar"];
433
+
}));;
434
+
- : string =
435
+
"Set error for draft1: invalidProperties: Unknown property: foobar"
436
+
]}
437
+
438
+
{1 Using with FastMail}
439
+
440
+
FastMail is a popular JMAP provider. Here's how to connect:
441
+
442
+
{[
443
+
(* Get a token from https://app.fastmail.com/settings/tokens *)
444
+
let token = "your-api-token"
445
+
446
+
(* The session URL for FastMail *)
447
+
let session_url = "https://api.fastmail.com/jmap/session"
448
+
449
+
(* For browser applications using jmap-brr: *)
450
+
let main () =
451
+
let open Fut.Syntax in
452
+
let* conn = Jmap_brr.get_session
453
+
~url:(Jstr.v session_url)
454
+
~token:(Jstr.v token)
455
+
in
456
+
match conn with
457
+
| Error e -> Brr.Console.(error [str "Error:"; e]); Fut.return ()
458
+
| Ok conn ->
459
+
let session = Jmap_brr.session conn in
460
+
Brr.Console.(log [str "Connected as:";
461
+
str (Jmap.Session.username session)]);
462
+
Fut.return ()
463
+
][
464
+
{err@mdx-error[
465
+
Line 9, characters 14-17:
466
+
Error: Unbound module Fut
467
+
Hint: Did you mean Fun?
468
+
]err}]}
469
+
470
+
{1 Summary}
471
+
472
+
JMAP (RFC 8620/8621) provides a modern, efficient protocol for email:
473
+
474
+
{ol
475
+
{- {b Sessions}: Discover capabilities and account information via GET request}
476
+
{- {b Batching}: Combine multiple method calls in one request}
477
+
{- {b References}: Use results from one method as input to another}
478
+
{- {b Type Safety}: The [jmap] library uses polymorphic variants for keywords and roles}
479
+
{- {b JSON Mapping}: OCaml types map cleanly to JMAP JSON structure}
480
+
{- {b Browser Support}: The [jmap-brr] package enables browser-based clients}}
481
+
482
+
The [jmap] library provides:
483
+
{ul
484
+
{- {!Jmap} - High-level interface with abstract types}
485
+
{- {!Jmap.Proto} - Low-level protocol types matching the RFCs}
486
+
{- {!Jmap.Chain} - Monadic interface for request chaining}
487
+
{- [Jmap_brr] - Browser support via Brr/js_of_ocaml (separate package)}}
488
+
489
+
{2 Key RFC References}
490
+
491
+
{ul
492
+
{- {{:https://www.rfc-editor.org/rfc/rfc8620}RFC 8620}: JMAP Core}
493
+
{- {{:https://www.rfc-editor.org/rfc/rfc8621}RFC 8621}: JMAP for Mail}
494
+
{- {{:https://www.rfc-editor.org/rfc/rfc7807}RFC 7807}: Problem Details for HTTP APIs}}
+13
-17
dune-project
+13
-17
dune-project
···
1
1
(lang dune 3.20)
2
2
3
+
(using mdx 0.4)
4
+
3
5
(name jmap)
4
6
5
7
(generate_opam_files true)
6
8
7
-
(source
8
-
(github avsm/ocaml-jmap))
9
+
(license ISC)
9
10
10
11
(authors "Anil Madhavapeddy <anil@recoil.org>")
11
12
12
13
(maintainers "Anil Madhavapeddy <anil@recoil.org>")
13
14
14
-
(license ISC)
15
+
(homepage "https://tangled.org/@anil.recoil.org/ocaml-jmap")
16
+
17
+
(bug_reports "https://tangled.org/@anil.recoil.org/ocaml-jmap/issues")
15
18
16
-
(documentation https://avsm.github.io/ocaml-jmap)
19
+
(maintenance_intent "(latest)")
17
20
18
21
(package
19
22
(name jmap)
20
23
(synopsis "JMAP protocol implementation for OCaml")
21
24
(description
22
-
"A complete implementation of the JSON Meta Application Protocol (JMAP) as specified in RFC 8620 (core) and RFC 8621 (mail).")
25
+
"A complete implementation of the JSON Meta Application Protocol (JMAP) as specified in RFC 8620 (core) and RFC 8621 (mail). Includes subpackages for Eio (jmap.eio) and browser (jmap.brr) clients.")
23
26
(depends
24
27
(ocaml (>= 5.4.0))
25
28
(jsont (>= 0.2.0))
26
29
json-pointer
27
-
(ptime (>= 1.0.0))))
28
-
29
-
(package
30
-
(name jmap-eio)
31
-
(synopsis "JMAP client for Eio")
32
-
(description "High-level JMAP client using Eio for async I/O and the Requests HTTP library.")
33
-
(depends
34
-
(ocaml (>= 5.4.0))
35
-
(jmap (= :version))
36
-
(jsont (>= 0.2.0))
37
-
eio
38
-
requests))
30
+
(ptime (>= 1.0.0))
31
+
(eio :with-test)
32
+
(requests :with-test)
33
+
(brr :with-test))
34
+
(depopts eio requests brr))
+2
-1
eio/dune
+2
-1
eio/dune
+1
eio/jmap_eio.ml
+1
eio/jmap_eio.ml
+7
eio/jmap_eio.mli
+7
eio/jmap_eio.mli
···
74
74
75
75
(** CLI configuration and cmdliner terms for JMAP tools. *)
76
76
module Cli = Cli
77
+
78
+
(** Method chaining with automatic result references.
79
+
80
+
Provides a monadic interface for building JMAP requests where method
81
+
calls can reference results from previous calls in the same request.
82
+
Call IDs are generated automatically. *)
83
+
module Chain = Jmap.Chain
-36
jmap-eio.opam
-36
jmap-eio.opam
···
1
-
# This file is generated by dune, edit dune-project instead
2
-
opam-version: "2.0"
3
-
synopsis: "JMAP client for Eio"
4
-
description:
5
-
"High-level JMAP client using Eio for async I/O and the Requests HTTP library."
6
-
maintainer: ["Anil Madhavapeddy <anil@recoil.org>"]
7
-
authors: ["Anil Madhavapeddy <anil@recoil.org>"]
8
-
license: "ISC"
9
-
homepage: "https://github.com/avsm/ocaml-jmap"
10
-
doc: "https://avsm.github.io/ocaml-jmap"
11
-
bug-reports: "https://github.com/avsm/ocaml-jmap/issues"
12
-
depends: [
13
-
"dune" {>= "3.20"}
14
-
"ocaml" {>= "5.4.0"}
15
-
"jmap" {= version}
16
-
"jsont" {>= "0.2.0"}
17
-
"eio"
18
-
"requests"
19
-
"odoc" {with-doc}
20
-
]
21
-
build: [
22
-
["dune" "subst"] {dev}
23
-
[
24
-
"dune"
25
-
"build"
26
-
"-p"
27
-
name
28
-
"-j"
29
-
jobs
30
-
"@install"
31
-
"@runtest" {with-test}
32
-
"@doc" {with-doc}
33
-
]
34
-
]
35
-
dev-repo: "git+https://github.com/avsm/ocaml-jmap.git"
36
-
x-maintenance-intent: ["(latest)"]
+7
-5
jmap.opam
+7
-5
jmap.opam
···
2
2
opam-version: "2.0"
3
3
synopsis: "JMAP protocol implementation for OCaml"
4
4
description:
5
-
"A complete implementation of the JSON Meta Application Protocol (JMAP) as specified in RFC 8620 (core) and RFC 8621 (mail)."
5
+
"A complete implementation of the JSON Meta Application Protocol (JMAP) as specified in RFC 8620 (core) and RFC 8621 (mail). Includes subpackages for Eio (jmap.eio) and browser (jmap.brr) clients."
6
6
maintainer: ["Anil Madhavapeddy <anil@recoil.org>"]
7
7
authors: ["Anil Madhavapeddy <anil@recoil.org>"]
8
8
license: "ISC"
9
-
homepage: "https://github.com/avsm/ocaml-jmap"
10
-
doc: "https://avsm.github.io/ocaml-jmap"
11
-
bug-reports: "https://github.com/avsm/ocaml-jmap/issues"
9
+
homepage: "https://tangled.org/@anil.recoil.org/ocaml-jmap"
10
+
bug-reports: "https://tangled.org/@anil.recoil.org/ocaml-jmap/issues"
12
11
depends: [
13
12
"dune" {>= "3.20"}
14
13
"ocaml" {>= "5.4.0"}
15
14
"jsont" {>= "0.2.0"}
16
15
"json-pointer"
17
16
"ptime" {>= "1.0.0"}
17
+
"eio" {with-test}
18
+
"requests" {with-test}
19
+
"brr" {with-test}
18
20
"odoc" {with-doc}
19
21
]
22
+
depopts: ["eio" "requests" "brr"]
20
23
build: [
21
24
["dune" "subst"] {dev}
22
25
[
···
31
34
"@doc" {with-doc}
32
35
]
33
36
]
34
-
dev-repo: "git+https://github.com/avsm/ocaml-jmap.git"
35
37
x-maintenance-intent: ["(latest)"]
+851
lib/core/chain.ml
+851
lib/core/chain.ml
···
1
+
(*---------------------------------------------------------------------------
2
+
Copyright (c) 2025 Anil Madhavapeddy. All rights reserved.
3
+
SPDX-License-Identifier: ISC
4
+
---------------------------------------------------------------------------*)
5
+
6
+
open Jmap_proto
7
+
8
+
(* Phantom types for handle kinds *)
9
+
type query
10
+
type get
11
+
type changes
12
+
type set
13
+
type query_changes
14
+
type copy
15
+
type import
16
+
type parse
17
+
18
+
(* Internal handle representation with GADT for response type *)
19
+
type (_, _) handle =
20
+
| Query_handle : {
21
+
call_id : string;
22
+
method_name : string;
23
+
} -> (query, Method.query_response) handle
24
+
| Query_changes_handle : {
25
+
call_id : string;
26
+
method_name : string;
27
+
} -> (query_changes, Method.query_changes_response) handle
28
+
| Email_get_handle : {
29
+
call_id : string;
30
+
method_name : string;
31
+
} -> (get, Email.t Method.get_response) handle
32
+
| Thread_get_handle : {
33
+
call_id : string;
34
+
method_name : string;
35
+
} -> (get, Thread.t Method.get_response) handle
36
+
| Mailbox_get_handle : {
37
+
call_id : string;
38
+
method_name : string;
39
+
} -> (get, Mailbox.t Method.get_response) handle
40
+
| Identity_get_handle : {
41
+
call_id : string;
42
+
method_name : string;
43
+
} -> (get, Identity.t Method.get_response) handle
44
+
| Submission_get_handle : {
45
+
call_id : string;
46
+
method_name : string;
47
+
} -> (get, Submission.t Method.get_response) handle
48
+
| Search_snippet_get_handle : {
49
+
call_id : string;
50
+
method_name : string;
51
+
} -> (get, Search_snippet.t Method.get_response) handle
52
+
| Vacation_get_handle : {
53
+
call_id : string;
54
+
method_name : string;
55
+
} -> (get, Vacation.t Method.get_response) handle
56
+
| Changes_handle : {
57
+
call_id : string;
58
+
method_name : string;
59
+
} -> (changes, Method.changes_response) handle
60
+
| Email_set_handle : {
61
+
call_id : string;
62
+
method_name : string;
63
+
} -> (set, Email.t Method.set_response) handle
64
+
| Mailbox_set_handle : {
65
+
call_id : string;
66
+
method_name : string;
67
+
} -> (set, Mailbox.t Method.set_response) handle
68
+
| Identity_set_handle : {
69
+
call_id : string;
70
+
method_name : string;
71
+
} -> (set, Identity.t Method.set_response) handle
72
+
| Submission_set_handle : {
73
+
call_id : string;
74
+
method_name : string;
75
+
} -> (set, Submission.t Method.set_response) handle
76
+
| Vacation_set_handle : {
77
+
call_id : string;
78
+
method_name : string;
79
+
} -> (set, Vacation.t Method.set_response) handle
80
+
| Email_copy_handle : {
81
+
call_id : string;
82
+
method_name : string;
83
+
} -> (copy, Email.t Method.copy_response) handle
84
+
| Raw_handle : {
85
+
call_id : string;
86
+
method_name : string;
87
+
} -> (unit, Jsont.Json.t) handle
88
+
89
+
let call_id : type k r. (k, r) handle -> string = function
90
+
| Query_handle h -> h.call_id
91
+
| Query_changes_handle h -> h.call_id
92
+
| Email_get_handle h -> h.call_id
93
+
| Thread_get_handle h -> h.call_id
94
+
| Mailbox_get_handle h -> h.call_id
95
+
| Identity_get_handle h -> h.call_id
96
+
| Submission_get_handle h -> h.call_id
97
+
| Search_snippet_get_handle h -> h.call_id
98
+
| Vacation_get_handle h -> h.call_id
99
+
| Changes_handle h -> h.call_id
100
+
| Email_set_handle h -> h.call_id
101
+
| Mailbox_set_handle h -> h.call_id
102
+
| Identity_set_handle h -> h.call_id
103
+
| Submission_set_handle h -> h.call_id
104
+
| Vacation_set_handle h -> h.call_id
105
+
| Email_copy_handle h -> h.call_id
106
+
| Raw_handle h -> h.call_id
107
+
108
+
let method_name : type k r. (k, r) handle -> string = function
109
+
| Query_handle h -> h.method_name
110
+
| Query_changes_handle h -> h.method_name
111
+
| Email_get_handle h -> h.method_name
112
+
| Thread_get_handle h -> h.method_name
113
+
| Mailbox_get_handle h -> h.method_name
114
+
| Identity_get_handle h -> h.method_name
115
+
| Submission_get_handle h -> h.method_name
116
+
| Search_snippet_get_handle h -> h.method_name
117
+
| Vacation_get_handle h -> h.method_name
118
+
| Changes_handle h -> h.method_name
119
+
| Email_set_handle h -> h.method_name
120
+
| Mailbox_set_handle h -> h.method_name
121
+
| Identity_set_handle h -> h.method_name
122
+
| Submission_set_handle h -> h.method_name
123
+
| Vacation_set_handle h -> h.method_name
124
+
| Email_copy_handle h -> h.method_name
125
+
| Raw_handle h -> h.method_name
126
+
127
+
(* Creation IDs *)
128
+
type 'a create_id = string
129
+
130
+
let created_id cid = Id.of_string_exn ("#" ^ cid)
131
+
let created_id_of_string s = Id.of_string_exn ("#" ^ s)
132
+
133
+
(* ID sources *)
134
+
type id_source =
135
+
| Ids of Id.t list
136
+
| Ref of Invocation.result_reference
137
+
138
+
let ids lst = Ids lst
139
+
let id x = Ids [x]
140
+
141
+
let make_ref ~call_id ~method_name ~path =
142
+
Ref (Invocation.result_reference_of_strings
143
+
~result_of:call_id
144
+
~name:method_name
145
+
~path)
146
+
147
+
let from_query : type r. (query, r) handle -> id_source = fun h ->
148
+
make_ref ~call_id:(call_id h) ~method_name:(method_name h) ~path:"/ids"
149
+
150
+
let from_get_ids : type r. (get, r) handle -> id_source = fun h ->
151
+
make_ref ~call_id:(call_id h) ~method_name:(method_name h) ~path:"/list/*/id"
152
+
153
+
let from_get_field : type r. (get, r) handle -> string -> id_source = fun h field ->
154
+
make_ref ~call_id:(call_id h) ~method_name:(method_name h)
155
+
~path:(Printf.sprintf "/list/*/%s" field)
156
+
157
+
let from_changes_created : type r. (changes, r) handle -> id_source = fun h ->
158
+
make_ref ~call_id:(call_id h) ~method_name:(method_name h) ~path:"/created"
159
+
160
+
let from_changes_updated : type r. (changes, r) handle -> id_source = fun h ->
161
+
make_ref ~call_id:(call_id h) ~method_name:(method_name h) ~path:"/updated"
162
+
163
+
let from_changes_destroyed : type r. (changes, r) handle -> id_source = fun h ->
164
+
make_ref ~call_id:(call_id h) ~method_name:(method_name h) ~path:"/destroyed"
165
+
166
+
let from_set_created : type r. (set, r) handle -> id_source = fun h ->
167
+
make_ref ~call_id:(call_id h) ~method_name:(method_name h) ~path:"/created/*/id"
168
+
169
+
let from_set_updated : type r. (set, r) handle -> id_source = fun h ->
170
+
make_ref ~call_id:(call_id h) ~method_name:(method_name h) ~path:"/updated"
171
+
172
+
let from_query_changes_removed : type r. (query_changes, r) handle -> id_source = fun h ->
173
+
make_ref ~call_id:(call_id h) ~method_name:(method_name h) ~path:"/removed"
174
+
175
+
let from_query_changes_added : type r. (query_changes, r) handle -> id_source = fun h ->
176
+
make_ref ~call_id:(call_id h) ~method_name:(method_name h) ~path:"/added/*/id"
177
+
178
+
let from_copy_created : type r. (copy, r) handle -> id_source = fun h ->
179
+
make_ref ~call_id:(call_id h) ~method_name:(method_name h) ~path:"/created/*/id"
180
+
181
+
let from_import_created : type r. (import, r) handle -> id_source = fun h ->
182
+
make_ref ~call_id:(call_id h) ~method_name:(method_name h) ~path:"/created/*/id"
183
+
184
+
(* Chain state *)
185
+
type state = {
186
+
mutable next_id : int;
187
+
mutable next_create_id : int;
188
+
mutable invocations : Invocation.t list;
189
+
}
190
+
191
+
(* Chain monad *)
192
+
type 'a t = state -> 'a
193
+
194
+
let return x _state = x
195
+
196
+
let bind m f state =
197
+
let a = m state in
198
+
f a state
199
+
200
+
let map f m state =
201
+
f (m state)
202
+
203
+
let both a b state =
204
+
let x = a state in
205
+
let y = b state in
206
+
(x, y)
207
+
208
+
let ( let* ) = bind
209
+
let ( let+ ) m f = map f m
210
+
let ( and* ) = both
211
+
let ( and+ ) = both
212
+
213
+
(* Building *)
214
+
let fresh_call_id state =
215
+
let id = Printf.sprintf "c%d" state.next_id in
216
+
state.next_id <- state.next_id + 1;
217
+
id
218
+
219
+
let fresh_create_id () state =
220
+
let id = Printf.sprintf "k%d" state.next_create_id in
221
+
state.next_create_id <- state.next_create_id + 1;
222
+
id
223
+
224
+
let record_invocation inv state =
225
+
state.invocations <- inv :: state.invocations
226
+
227
+
let build ~capabilities chain =
228
+
let state = { next_id = 0; next_create_id = 0; invocations = [] } in
229
+
let result = chain state in
230
+
let request = Request.create
231
+
~using:capabilities
232
+
~method_calls:(List.rev state.invocations)
233
+
()
234
+
in
235
+
(request, result)
236
+
237
+
let build_request ~capabilities chain =
238
+
fst (build ~capabilities chain)
239
+
240
+
(* JSON helpers - exported *)
241
+
let json_null = Jsont.Null ((), Jsont.Meta.none)
242
+
243
+
let json_bool b = Jsont.Bool (b, Jsont.Meta.none)
244
+
245
+
let json_string s = Jsont.String (s, Jsont.Meta.none)
246
+
247
+
let json_int n = Jsont.Number (Int64.to_float n, Jsont.Meta.none)
248
+
249
+
let json_name s = (s, Jsont.Meta.none)
250
+
251
+
let json_obj fields =
252
+
let fields' = List.map (fun (k, v) -> (json_name k, v)) fields in
253
+
Jsont.Object (fields', Jsont.Meta.none)
254
+
255
+
let json_array items = Jsont.Array (items, Jsont.Meta.none)
256
+
257
+
(* JSON helpers - internal *)
258
+
let json_of_id id =
259
+
Jsont.String (Id.to_string id, Jsont.Meta.none)
260
+
261
+
let json_of_id_list ids =
262
+
let items = List.map json_of_id ids in
263
+
Jsont.Array (items, Jsont.Meta.none)
264
+
265
+
let json_of_string_list strs =
266
+
let items = List.map json_string strs in
267
+
Jsont.Array (items, Jsont.Meta.none)
268
+
269
+
let json_map pairs =
270
+
let fields' = List.map (fun (k, v) -> (json_name k, v)) pairs in
271
+
Jsont.Object (fields', Jsont.Meta.none)
272
+
273
+
let encode_to_json jsont value =
274
+
match Jsont.Json.encode' jsont value with
275
+
| Ok j -> j
276
+
| Error _ -> json_obj []
277
+
278
+
let encode_list_to_json jsont values =
279
+
match Jsont.Json.encode' (Jsont.list jsont) values with
280
+
| Ok j -> j
281
+
| Error _ -> Jsont.Array ([], Jsont.Meta.none)
282
+
283
+
(* Add id_source to args *)
284
+
let add_ids_arg args = function
285
+
| None -> args
286
+
| Some (Ids ids) ->
287
+
("ids", json_of_id_list ids) :: args
288
+
| Some (Ref ref_) ->
289
+
let ref_json = encode_to_json Invocation.result_reference_jsont ref_ in
290
+
("#ids", ref_json) :: args
291
+
292
+
let add_destroy_arg args = function
293
+
| None -> args
294
+
| Some (Ids ids) ->
295
+
("destroy", json_of_id_list ids) :: args
296
+
| Some (Ref ref_) ->
297
+
let ref_json = encode_to_json Invocation.result_reference_jsont ref_ in
298
+
("#destroy", ref_json) :: args
299
+
300
+
(* Query builder helper *)
301
+
let build_query_args ~account_id ?filter ?filter_jsont ?sort ?position ?anchor
302
+
?anchor_offset ?limit ?calculate_total () =
303
+
let args = [ ("accountId", json_of_id account_id) ] in
304
+
let args = match filter, filter_jsont with
305
+
| Some f, Some jsont -> ("filter", encode_to_json jsont f) :: args
306
+
| _ -> args
307
+
in
308
+
let args = match sort with
309
+
| None -> args
310
+
| Some comparators -> ("sort", encode_list_to_json Filter.comparator_jsont comparators) :: args
311
+
in
312
+
let args = match position with
313
+
| None -> args
314
+
| Some n -> ("position", json_int n) :: args
315
+
in
316
+
let args = match anchor with
317
+
| None -> args
318
+
| Some id -> ("anchor", json_of_id id) :: args
319
+
in
320
+
let args = match anchor_offset with
321
+
| None -> args
322
+
| Some n -> ("anchorOffset", json_int n) :: args
323
+
in
324
+
let args = match limit with
325
+
| None -> args
326
+
| Some n -> ("limit", json_int n) :: args
327
+
in
328
+
let args = match calculate_total with
329
+
| None -> args
330
+
| Some b -> ("calculateTotal", json_bool b) :: args
331
+
in
332
+
args
333
+
334
+
(* Changes builder helper *)
335
+
let build_changes_args ~account_id ~since_state ?max_changes () =
336
+
let args = [
337
+
("accountId", json_of_id account_id);
338
+
("sinceState", json_string since_state);
339
+
] in
340
+
let args = match max_changes with
341
+
| None -> args
342
+
| Some n -> ("maxChanges", json_int n) :: args
343
+
in
344
+
args
345
+
346
+
(* QueryChanges builder helper *)
347
+
let build_query_changes_args ~account_id ~since_query_state ?filter ?filter_jsont
348
+
?sort ?max_changes ?up_to_id ?calculate_total () =
349
+
let args = [
350
+
("accountId", json_of_id account_id);
351
+
("sinceQueryState", json_string since_query_state);
352
+
] in
353
+
let args = match filter, filter_jsont with
354
+
| Some f, Some jsont -> ("filter", encode_to_json jsont f) :: args
355
+
| _ -> args
356
+
in
357
+
let args = match sort with
358
+
| None -> args
359
+
| Some comparators -> ("sort", encode_list_to_json Filter.comparator_jsont comparators) :: args
360
+
in
361
+
let args = match max_changes with
362
+
| None -> args
363
+
| Some n -> ("maxChanges", json_int n) :: args
364
+
in
365
+
let args = match up_to_id with
366
+
| None -> args
367
+
| Some id -> ("upToId", json_of_id id) :: args
368
+
in
369
+
let args = match calculate_total with
370
+
| None -> args
371
+
| Some b -> ("calculateTotal", json_bool b) :: args
372
+
in
373
+
args
374
+
375
+
(* Set builder helper *)
376
+
let build_set_args ~account_id ?if_in_state ?create ?update ?destroy () =
377
+
let args = [ ("accountId", json_of_id account_id) ] in
378
+
let args = match if_in_state with
379
+
| None -> args
380
+
| Some s -> ("ifInState", json_string s) :: args
381
+
in
382
+
let args = match create with
383
+
| None | Some [] -> args
384
+
| Some items ->
385
+
let create_map = json_map (List.map (fun (cid, obj) -> (cid, obj)) items) in
386
+
("create", create_map) :: args
387
+
in
388
+
let args = match update with
389
+
| None | Some [] -> args
390
+
| Some items ->
391
+
let update_map = json_map (List.map (fun (id, patch) -> (Id.to_string id, patch)) items) in
392
+
("update", update_map) :: args
393
+
in
394
+
let args = add_destroy_arg args destroy in
395
+
args
396
+
397
+
(* Method builders *)
398
+
399
+
let email_query ~account_id ?filter ?sort ?position ?anchor ?anchor_offset
400
+
?limit ?calculate_total ?collapse_threads () state =
401
+
let call_id = fresh_call_id state in
402
+
let args = build_query_args ~account_id ?filter
403
+
~filter_jsont:Mail_filter.email_filter_jsont
404
+
?sort ?position ?anchor ?anchor_offset ?limit ?calculate_total () in
405
+
let args = match collapse_threads with
406
+
| None -> args
407
+
| Some b -> ("collapseThreads", json_bool b) :: args
408
+
in
409
+
let inv = Invocation.create
410
+
~name:"Email/query"
411
+
~arguments:(json_obj args)
412
+
~method_call_id:call_id
413
+
in
414
+
record_invocation inv state;
415
+
Query_handle { call_id; method_name = "Email/query" }
416
+
417
+
let email_get ~account_id ?ids ?properties ?body_properties
418
+
?fetch_text_body_values ?fetch_html_body_values ?fetch_all_body_values
419
+
?max_body_value_bytes () state =
420
+
let call_id = fresh_call_id state in
421
+
let args = [ ("accountId", json_of_id account_id) ] in
422
+
let args = add_ids_arg args ids in
423
+
let args = match properties with
424
+
| None -> args
425
+
| Some props -> ("properties", json_of_string_list props) :: args
426
+
in
427
+
let args = match body_properties with
428
+
| None -> args
429
+
| Some props -> ("bodyProperties", json_of_string_list props) :: args
430
+
in
431
+
let args = match fetch_text_body_values with
432
+
| None -> args
433
+
| Some b -> ("fetchTextBodyValues", json_bool b) :: args
434
+
in
435
+
let args = match fetch_html_body_values with
436
+
| None -> args
437
+
| Some b -> ("fetchHTMLBodyValues", json_bool b) :: args
438
+
in
439
+
let args = match fetch_all_body_values with
440
+
| None -> args
441
+
| Some b -> ("fetchAllBodyValues", json_bool b) :: args
442
+
in
443
+
let args = match max_body_value_bytes with
444
+
| None -> args
445
+
| Some n -> ("maxBodyValueBytes", json_int n) :: args
446
+
in
447
+
let inv = Invocation.create
448
+
~name:"Email/get"
449
+
~arguments:(json_obj args)
450
+
~method_call_id:call_id
451
+
in
452
+
record_invocation inv state;
453
+
Email_get_handle { call_id; method_name = "Email/get" }
454
+
455
+
let email_changes ~account_id ~since_state ?max_changes () state =
456
+
let call_id = fresh_call_id state in
457
+
let args = build_changes_args ~account_id ~since_state ?max_changes () in
458
+
let inv = Invocation.create
459
+
~name:"Email/changes"
460
+
~arguments:(json_obj args)
461
+
~method_call_id:call_id
462
+
in
463
+
record_invocation inv state;
464
+
Changes_handle { call_id; method_name = "Email/changes" }
465
+
466
+
let email_query_changes ~account_id ~since_query_state ?filter ?sort
467
+
?max_changes ?up_to_id ?calculate_total () state =
468
+
let call_id = fresh_call_id state in
469
+
let args = build_query_changes_args ~account_id ~since_query_state
470
+
?filter ~filter_jsont:Mail_filter.email_filter_jsont
471
+
?sort ?max_changes ?up_to_id ?calculate_total () in
472
+
let inv = Invocation.create
473
+
~name:"Email/queryChanges"
474
+
~arguments:(json_obj args)
475
+
~method_call_id:call_id
476
+
in
477
+
record_invocation inv state;
478
+
Query_changes_handle { call_id; method_name = "Email/queryChanges" }
479
+
480
+
let email_set ~account_id ?if_in_state ?create ?update ?destroy () state =
481
+
let call_id = fresh_call_id state in
482
+
let args = build_set_args ~account_id ?if_in_state ?create ?update ?destroy () in
483
+
let inv = Invocation.create
484
+
~name:"Email/set"
485
+
~arguments:(json_obj args)
486
+
~method_call_id:call_id
487
+
in
488
+
record_invocation inv state;
489
+
Email_set_handle { call_id; method_name = "Email/set" }
490
+
491
+
let email_copy ~from_account_id ~account_id ?if_from_in_state ?if_in_state
492
+
?create ?on_success_destroy_original ?destroy_from_if_in_state () state =
493
+
let call_id = fresh_call_id state in
494
+
let args = [
495
+
("fromAccountId", json_of_id from_account_id);
496
+
("accountId", json_of_id account_id);
497
+
] in
498
+
let args = match if_from_in_state with
499
+
| None -> args
500
+
| Some s -> ("ifFromInState", json_string s) :: args
501
+
in
502
+
let args = match if_in_state with
503
+
| None -> args
504
+
| Some s -> ("ifInState", json_string s) :: args
505
+
in
506
+
let args = match create with
507
+
| None | Some [] -> args
508
+
| Some items ->
509
+
let create_map = json_map (List.map (fun (id, obj) -> (Id.to_string id, obj)) items) in
510
+
("create", create_map) :: args
511
+
in
512
+
let args = match on_success_destroy_original with
513
+
| None -> args
514
+
| Some b -> ("onSuccessDestroyOriginal", json_bool b) :: args
515
+
in
516
+
let args = match destroy_from_if_in_state with
517
+
| None -> args
518
+
| Some s -> ("destroyFromIfInState", json_string s) :: args
519
+
in
520
+
let inv = Invocation.create
521
+
~name:"Email/copy"
522
+
~arguments:(json_obj args)
523
+
~method_call_id:call_id
524
+
in
525
+
record_invocation inv state;
526
+
Email_copy_handle { call_id; method_name = "Email/copy" }
527
+
528
+
let thread_get ~account_id ?ids () state =
529
+
let call_id = fresh_call_id state in
530
+
let args = [ ("accountId", json_of_id account_id) ] in
531
+
let args = add_ids_arg args ids in
532
+
let inv = Invocation.create
533
+
~name:"Thread/get"
534
+
~arguments:(json_obj args)
535
+
~method_call_id:call_id
536
+
in
537
+
record_invocation inv state;
538
+
Thread_get_handle { call_id; method_name = "Thread/get" }
539
+
540
+
let thread_changes ~account_id ~since_state ?max_changes () state =
541
+
let call_id = fresh_call_id state in
542
+
let args = build_changes_args ~account_id ~since_state ?max_changes () in
543
+
let inv = Invocation.create
544
+
~name:"Thread/changes"
545
+
~arguments:(json_obj args)
546
+
~method_call_id:call_id
547
+
in
548
+
record_invocation inv state;
549
+
Changes_handle { call_id; method_name = "Thread/changes" }
550
+
551
+
let mailbox_query ~account_id ?filter ?sort ?position ?anchor ?anchor_offset
552
+
?limit ?calculate_total () state =
553
+
let call_id = fresh_call_id state in
554
+
let args = build_query_args ~account_id ?filter
555
+
~filter_jsont:Mail_filter.mailbox_filter_jsont
556
+
?sort ?position ?anchor ?anchor_offset ?limit ?calculate_total () in
557
+
let inv = Invocation.create
558
+
~name:"Mailbox/query"
559
+
~arguments:(json_obj args)
560
+
~method_call_id:call_id
561
+
in
562
+
record_invocation inv state;
563
+
Query_handle { call_id; method_name = "Mailbox/query" }
564
+
565
+
let mailbox_get ~account_id ?ids ?properties () state =
566
+
let call_id = fresh_call_id state in
567
+
let args = [ ("accountId", json_of_id account_id) ] in
568
+
let args = add_ids_arg args ids in
569
+
let args = match properties with
570
+
| None -> args
571
+
| Some props -> ("properties", json_of_string_list props) :: args
572
+
in
573
+
let inv = Invocation.create
574
+
~name:"Mailbox/get"
575
+
~arguments:(json_obj args)
576
+
~method_call_id:call_id
577
+
in
578
+
record_invocation inv state;
579
+
Mailbox_get_handle { call_id; method_name = "Mailbox/get" }
580
+
581
+
let mailbox_changes ~account_id ~since_state ?max_changes () state =
582
+
let call_id = fresh_call_id state in
583
+
let args = build_changes_args ~account_id ~since_state ?max_changes () in
584
+
let inv = Invocation.create
585
+
~name:"Mailbox/changes"
586
+
~arguments:(json_obj args)
587
+
~method_call_id:call_id
588
+
in
589
+
record_invocation inv state;
590
+
Changes_handle { call_id; method_name = "Mailbox/changes" }
591
+
592
+
let mailbox_query_changes ~account_id ~since_query_state ?filter ?sort
593
+
?max_changes ?up_to_id ?calculate_total () state =
594
+
let call_id = fresh_call_id state in
595
+
let args = build_query_changes_args ~account_id ~since_query_state
596
+
?filter ~filter_jsont:Mail_filter.mailbox_filter_jsont
597
+
?sort ?max_changes ?up_to_id ?calculate_total () in
598
+
let inv = Invocation.create
599
+
~name:"Mailbox/queryChanges"
600
+
~arguments:(json_obj args)
601
+
~method_call_id:call_id
602
+
in
603
+
record_invocation inv state;
604
+
Query_changes_handle { call_id; method_name = "Mailbox/queryChanges" }
605
+
606
+
let mailbox_set ~account_id ?if_in_state ?create ?update ?destroy
607
+
?on_destroy_remove_emails () state =
608
+
let call_id = fresh_call_id state in
609
+
let args = build_set_args ~account_id ?if_in_state ?create ?update ?destroy () in
610
+
let args = match on_destroy_remove_emails with
611
+
| None -> args
612
+
| Some b -> ("onDestroyRemoveEmails", json_bool b) :: args
613
+
in
614
+
let inv = Invocation.create
615
+
~name:"Mailbox/set"
616
+
~arguments:(json_obj args)
617
+
~method_call_id:call_id
618
+
in
619
+
record_invocation inv state;
620
+
Mailbox_set_handle { call_id; method_name = "Mailbox/set" }
621
+
622
+
let identity_get ~account_id ?ids ?properties () state =
623
+
let call_id = fresh_call_id state in
624
+
let args = [ ("accountId", json_of_id account_id) ] in
625
+
let args = add_ids_arg args ids in
626
+
let args = match properties with
627
+
| None -> args
628
+
| Some props -> ("properties", json_of_string_list props) :: args
629
+
in
630
+
let inv = Invocation.create
631
+
~name:"Identity/get"
632
+
~arguments:(json_obj args)
633
+
~method_call_id:call_id
634
+
in
635
+
record_invocation inv state;
636
+
Identity_get_handle { call_id; method_name = "Identity/get" }
637
+
638
+
let identity_changes ~account_id ~since_state ?max_changes () state =
639
+
let call_id = fresh_call_id state in
640
+
let args = build_changes_args ~account_id ~since_state ?max_changes () in
641
+
let inv = Invocation.create
642
+
~name:"Identity/changes"
643
+
~arguments:(json_obj args)
644
+
~method_call_id:call_id
645
+
in
646
+
record_invocation inv state;
647
+
Changes_handle { call_id; method_name = "Identity/changes" }
648
+
649
+
let identity_set ~account_id ?if_in_state ?create ?update ?destroy () state =
650
+
let call_id = fresh_call_id state in
651
+
let args = build_set_args ~account_id ?if_in_state ?create ?update ?destroy () in
652
+
let inv = Invocation.create
653
+
~name:"Identity/set"
654
+
~arguments:(json_obj args)
655
+
~method_call_id:call_id
656
+
in
657
+
record_invocation inv state;
658
+
Identity_set_handle { call_id; method_name = "Identity/set" }
659
+
660
+
let email_submission_query ~account_id ?filter ?sort ?position ?anchor
661
+
?anchor_offset ?limit ?calculate_total () state =
662
+
let call_id = fresh_call_id state in
663
+
let args = build_query_args ~account_id ?filter
664
+
~filter_jsont:Mail_filter.submission_filter_jsont
665
+
?sort ?position ?anchor ?anchor_offset ?limit ?calculate_total () in
666
+
let inv = Invocation.create
667
+
~name:"EmailSubmission/query"
668
+
~arguments:(json_obj args)
669
+
~method_call_id:call_id
670
+
in
671
+
record_invocation inv state;
672
+
Query_handle { call_id; method_name = "EmailSubmission/query" }
673
+
674
+
let email_submission_get ~account_id ?ids ?properties () state =
675
+
let call_id = fresh_call_id state in
676
+
let args = [ ("accountId", json_of_id account_id) ] in
677
+
let args = add_ids_arg args ids in
678
+
let args = match properties with
679
+
| None -> args
680
+
| Some props -> ("properties", json_of_string_list props) :: args
681
+
in
682
+
let inv = Invocation.create
683
+
~name:"EmailSubmission/get"
684
+
~arguments:(json_obj args)
685
+
~method_call_id:call_id
686
+
in
687
+
record_invocation inv state;
688
+
Submission_get_handle { call_id; method_name = "EmailSubmission/get" }
689
+
690
+
let email_submission_changes ~account_id ~since_state ?max_changes () state =
691
+
let call_id = fresh_call_id state in
692
+
let args = build_changes_args ~account_id ~since_state ?max_changes () in
693
+
let inv = Invocation.create
694
+
~name:"EmailSubmission/changes"
695
+
~arguments:(json_obj args)
696
+
~method_call_id:call_id
697
+
in
698
+
record_invocation inv state;
699
+
Changes_handle { call_id; method_name = "EmailSubmission/changes" }
700
+
701
+
let email_submission_query_changes ~account_id ~since_query_state ?filter ?sort
702
+
?max_changes ?up_to_id ?calculate_total () state =
703
+
let call_id = fresh_call_id state in
704
+
let args = build_query_changes_args ~account_id ~since_query_state
705
+
?filter ~filter_jsont:Mail_filter.submission_filter_jsont
706
+
?sort ?max_changes ?up_to_id ?calculate_total () in
707
+
let inv = Invocation.create
708
+
~name:"EmailSubmission/queryChanges"
709
+
~arguments:(json_obj args)
710
+
~method_call_id:call_id
711
+
in
712
+
record_invocation inv state;
713
+
Query_changes_handle { call_id; method_name = "EmailSubmission/queryChanges" }
714
+
715
+
let email_submission_set ~account_id ?if_in_state ?create ?update ?destroy
716
+
?on_success_update_email ?on_success_destroy_email () state =
717
+
let call_id = fresh_call_id state in
718
+
let args = build_set_args ~account_id ?if_in_state ?create ?update ?destroy () in
719
+
let args = match on_success_update_email with
720
+
| None | Some [] -> args
721
+
| Some items ->
722
+
let update_map = json_map items in
723
+
("onSuccessUpdateEmail", update_map) :: args
724
+
in
725
+
let args = match on_success_destroy_email with
726
+
| None | Some [] -> args
727
+
| Some ids ->
728
+
("onSuccessDestroyEmail", json_of_string_list ids) :: args
729
+
in
730
+
let inv = Invocation.create
731
+
~name:"EmailSubmission/set"
732
+
~arguments:(json_obj args)
733
+
~method_call_id:call_id
734
+
in
735
+
record_invocation inv state;
736
+
Submission_set_handle { call_id; method_name = "EmailSubmission/set" }
737
+
738
+
let search_snippet_get ~account_id ~filter ~email_ids () state =
739
+
let call_id = fresh_call_id state in
740
+
let args = [ ("accountId", json_of_id account_id) ] in
741
+
let args = ("filter", encode_to_json Mail_filter.email_filter_jsont filter) :: args in
742
+
let args = match email_ids with
743
+
| Ids ids -> ("emailIds", json_of_id_list ids) :: args
744
+
| Ref ref_ ->
745
+
let ref_json = encode_to_json Invocation.result_reference_jsont ref_ in
746
+
("#emailIds", ref_json) :: args
747
+
in
748
+
let inv = Invocation.create
749
+
~name:"SearchSnippet/get"
750
+
~arguments:(json_obj args)
751
+
~method_call_id:call_id
752
+
in
753
+
record_invocation inv state;
754
+
Search_snippet_get_handle { call_id; method_name = "SearchSnippet/get" }
755
+
756
+
let vacation_response_get ~account_id ?properties () state =
757
+
let call_id = fresh_call_id state in
758
+
let args = [ ("accountId", json_of_id account_id) ] in
759
+
let args = match properties with
760
+
| None -> args
761
+
| Some props -> ("properties", json_of_string_list props) :: args
762
+
in
763
+
let inv = Invocation.create
764
+
~name:"VacationResponse/get"
765
+
~arguments:(json_obj args)
766
+
~method_call_id:call_id
767
+
in
768
+
record_invocation inv state;
769
+
Vacation_get_handle { call_id; method_name = "VacationResponse/get" }
770
+
771
+
let vacation_response_set ~account_id ?if_in_state ~update () state =
772
+
let call_id = fresh_call_id state in
773
+
let args = [ ("accountId", json_of_id account_id) ] in
774
+
let args = match if_in_state with
775
+
| None -> args
776
+
| Some s -> ("ifInState", json_string s) :: args
777
+
in
778
+
let args = ("update", json_map [("singleton", update)]) :: args in
779
+
let inv = Invocation.create
780
+
~name:"VacationResponse/set"
781
+
~arguments:(json_obj args)
782
+
~method_call_id:call_id
783
+
in
784
+
record_invocation inv state;
785
+
Vacation_set_handle { call_id; method_name = "VacationResponse/set" }
786
+
787
+
let raw_invocation ~name ~arguments state =
788
+
let call_id = fresh_call_id state in
789
+
let inv = Invocation.create
790
+
~name
791
+
~arguments
792
+
~method_call_id:call_id
793
+
in
794
+
record_invocation inv state;
795
+
Raw_handle { call_id; method_name = name }
796
+
797
+
(* Response parsing *)
798
+
799
+
let find_invocation ~call_id response =
800
+
List.find_opt
801
+
(fun inv -> Invocation.method_call_id inv = call_id)
802
+
(Response.method_responses response)
803
+
804
+
let parse : type k r. (k, r) handle -> Response.t -> (r, Jsont.Error.t) result =
805
+
fun handle response ->
806
+
let cid = call_id handle in
807
+
match find_invocation ~call_id:cid response with
808
+
| None ->
809
+
Error (Jsont.Error.msgf Jsont.Meta.none "No response found for call_id: %s" cid)
810
+
| Some inv ->
811
+
let args = Invocation.arguments inv in
812
+
match handle with
813
+
| Query_handle _ ->
814
+
Jsont.Json.decode' Method.query_response_jsont args
815
+
| Query_changes_handle _ ->
816
+
Jsont.Json.decode' Method.query_changes_response_jsont args
817
+
| Email_get_handle _ ->
818
+
Jsont.Json.decode' (Method.get_response_jsont Email.jsont) args
819
+
| Thread_get_handle _ ->
820
+
Jsont.Json.decode' (Method.get_response_jsont Thread.jsont) args
821
+
| Mailbox_get_handle _ ->
822
+
Jsont.Json.decode' (Method.get_response_jsont Mailbox.jsont) args
823
+
| Identity_get_handle _ ->
824
+
Jsont.Json.decode' (Method.get_response_jsont Identity.jsont) args
825
+
| Submission_get_handle _ ->
826
+
Jsont.Json.decode' (Method.get_response_jsont Submission.jsont) args
827
+
| Search_snippet_get_handle _ ->
828
+
Jsont.Json.decode' (Method.get_response_jsont Search_snippet.jsont) args
829
+
| Vacation_get_handle _ ->
830
+
Jsont.Json.decode' (Method.get_response_jsont Vacation.jsont) args
831
+
| Changes_handle _ ->
832
+
Jsont.Json.decode' Method.changes_response_jsont args
833
+
| Email_set_handle _ ->
834
+
Jsont.Json.decode' (Method.set_response_jsont Email.jsont) args
835
+
| Mailbox_set_handle _ ->
836
+
Jsont.Json.decode' (Method.set_response_jsont Mailbox.jsont) args
837
+
| Identity_set_handle _ ->
838
+
Jsont.Json.decode' (Method.set_response_jsont Identity.jsont) args
839
+
| Submission_set_handle _ ->
840
+
Jsont.Json.decode' (Method.set_response_jsont Submission.jsont) args
841
+
| Vacation_set_handle _ ->
842
+
Jsont.Json.decode' (Method.set_response_jsont Vacation.jsont) args
843
+
| Email_copy_handle _ ->
844
+
Jsont.Json.decode' (Method.copy_response_jsont Email.jsont) args
845
+
| Raw_handle _ ->
846
+
Ok args
847
+
848
+
let parse_exn handle response =
849
+
match parse handle response with
850
+
| Ok r -> r
851
+
| Error e -> failwith (Jsont.Error.to_string e)
+556
lib/core/chain.mli
+556
lib/core/chain.mli
···
1
+
(*---------------------------------------------------------------------------
2
+
Copyright (c) 2025 Anil Madhavapeddy. All rights reserved.
3
+
SPDX-License-Identifier: ISC
4
+
---------------------------------------------------------------------------*)
5
+
6
+
(** JMAP method chaining with automatic result references.
7
+
8
+
This module provides a monadic interface for building JMAP requests
9
+
where method calls can reference results from previous calls in the
10
+
same request. Call IDs are generated automatically.
11
+
12
+
{2 Basic Example}
13
+
14
+
Query for emails and fetch their details in a single request:
15
+
{[
16
+
let open Jmap.Chain in
17
+
let request, emails = build ~capabilities:[core; mail] begin
18
+
let* query = email_query ~account_id
19
+
~filter:(Condition { in_mailbox = Some inbox_id; _ })
20
+
~limit:50L ()
21
+
in
22
+
let* emails = email_get ~account_id
23
+
~ids:(from_query query)
24
+
~properties:["subject"; "from"; "receivedAt"]
25
+
()
26
+
in
27
+
return emails
28
+
end in
29
+
match Client.request client request with
30
+
| Ok response ->
31
+
let emails = parse emails response in
32
+
...
33
+
]}
34
+
35
+
{2 Creation and Submission}
36
+
37
+
Create a draft email and submit it in one request:
38
+
{[
39
+
let* set_h, draft_cid = email_set ~account_id
40
+
~create:[email_create ~mailbox_ids:[drafts_id] ~subject:"Hello" ...]
41
+
()
42
+
in
43
+
let* _ = email_submission_set ~account_id
44
+
~create:[submission_create
45
+
~email_id:(created_id draft_cid)
46
+
~identity_id]
47
+
()
48
+
in
49
+
return set_h
50
+
]}
51
+
52
+
{2 Multi-step Chains}
53
+
54
+
The RFC 8620 example - fetch from/date/subject for all emails in
55
+
the first 10 threads in the inbox:
56
+
{[
57
+
let* q = email_query ~account_id
58
+
~filter:(Condition { in_mailbox = Some inbox_id; _ })
59
+
~sort:[comparator ~is_ascending:false "receivedAt"]
60
+
~collapse_threads:true ~limit:10L ()
61
+
in
62
+
let* e1 = email_get ~account_id
63
+
~ids:(from_query q)
64
+
~properties:["threadId"]
65
+
()
66
+
in
67
+
let* threads = thread_get ~account_id
68
+
~ids:(from_get_field e1 "threadId")
69
+
()
70
+
in
71
+
let* e2 = email_get ~account_id
72
+
~ids:(from_get_field threads "emailIds")
73
+
~properties:["from"; "receivedAt"; "subject"]
74
+
()
75
+
in
76
+
return e2
77
+
]} *)
78
+
79
+
(** {1 Handles}
80
+
81
+
Method invocations return handles that encode both the method kind
82
+
(for building result references) and the exact response type
83
+
(for type-safe parsing). *)
84
+
85
+
(** Phantom type for query method handles. *)
86
+
type query
87
+
88
+
(** Phantom type for get method handles. *)
89
+
type get
90
+
91
+
(** Phantom type for changes method handles. *)
92
+
type changes
93
+
94
+
(** Phantom type for set method handles. *)
95
+
type set
96
+
97
+
(** Phantom type for query_changes method handles. *)
98
+
type query_changes
99
+
100
+
(** Phantom type for copy method handles. *)
101
+
type copy
102
+
103
+
(** Phantom type for import method handles. *)
104
+
type import
105
+
106
+
(** Phantom type for parse method handles. *)
107
+
type parse
108
+
109
+
(** A handle to a method invocation.
110
+
111
+
The first type parameter indicates the method kind (query/get/changes/set/...),
112
+
used for building result references. The second type parameter is the
113
+
parsed response type, enabling type-safe parsing via {!parse}. *)
114
+
type (_, _) handle
115
+
116
+
val call_id : (_, _) handle -> string
117
+
(** [call_id h] returns the auto-generated call ID for this invocation. *)
118
+
119
+
val method_name : (_, _) handle -> string
120
+
(** [method_name h] returns the method name (e.g., "Email/query"). *)
121
+
122
+
(** {1 Creation IDs}
123
+
124
+
When creating objects via [/set] methods, you can reference the
125
+
server-assigned ID before the request completes using creation IDs. *)
126
+
127
+
type 'a create_id
128
+
(** A creation ID for an object of type ['a]. Used to reference
129
+
newly created objects within the same request. *)
130
+
131
+
val created_id : _ create_id -> Jmap_proto.Id.t
132
+
(** [created_id cid] returns a placeholder ID (["#cN"]) that the server
133
+
will substitute with the real ID. Use this to reference a created
134
+
object in subsequent method calls within the same request. *)
135
+
136
+
val created_id_of_string : string -> Jmap_proto.Id.t
137
+
(** [created_id_of_string s] returns a placeholder ID for a string creation ID.
138
+
For example, [created_id_of_string "draft1"] returns ["#draft1"]. *)
139
+
140
+
(** {1 ID Sources}
141
+
142
+
Methods that accept IDs can take them either as concrete values
143
+
or as references to results from previous method calls. *)
144
+
145
+
type id_source =
146
+
| Ids of Jmap_proto.Id.t list
147
+
(** Concrete list of IDs. *)
148
+
| Ref of Jmap_proto.Invocation.result_reference
149
+
(** Back-reference to a previous method's result. *)
150
+
151
+
val ids : Jmap_proto.Id.t list -> id_source
152
+
(** [ids lst] provides concrete IDs. *)
153
+
154
+
val id : Jmap_proto.Id.t -> id_source
155
+
(** [id x] provides a single concrete ID. *)
156
+
157
+
(** {2 References from Query} *)
158
+
159
+
val from_query : (query, _) handle -> id_source
160
+
(** [from_query h] references [/ids] from a query response. *)
161
+
162
+
(** {2 References from Get} *)
163
+
164
+
val from_get_ids : (get, _) handle -> id_source
165
+
(** [from_get_ids h] references [/list/*/id] from a get response. *)
166
+
167
+
val from_get_field : (get, _) handle -> string -> id_source
168
+
(** [from_get_field h field] references [/list/*/field] from a get response.
169
+
Common fields: ["threadId"], ["emailIds"], ["mailboxIds"]. *)
170
+
171
+
(** {2 References from Changes} *)
172
+
173
+
val from_changes_created : (changes, _) handle -> id_source
174
+
(** [from_changes_created h] references [/created] from a changes response. *)
175
+
176
+
val from_changes_updated : (changes, _) handle -> id_source
177
+
(** [from_changes_updated h] references [/updated] from a changes response. *)
178
+
179
+
val from_changes_destroyed : (changes, _) handle -> id_source
180
+
(** [from_changes_destroyed h] references [/destroyed] from a changes response. *)
181
+
182
+
(** {2 References from Set} *)
183
+
184
+
val from_set_created : (set, _) handle -> id_source
185
+
(** [from_set_created h] references [/created/*/id] - IDs of objects created
186
+
by a set operation. *)
187
+
188
+
val from_set_updated : (set, _) handle -> id_source
189
+
(** [from_set_updated h] references [/updated] - IDs of objects updated. *)
190
+
191
+
(** {2 References from QueryChanges} *)
192
+
193
+
val from_query_changes_removed : (query_changes, _) handle -> id_source
194
+
(** [from_query_changes_removed h] references [/removed] from queryChanges. *)
195
+
196
+
val from_query_changes_added : (query_changes, _) handle -> id_source
197
+
(** [from_query_changes_added h] references [/added/*/id] from queryChanges. *)
198
+
199
+
(** {2 References from Copy} *)
200
+
201
+
val from_copy_created : (copy, _) handle -> id_source
202
+
(** [from_copy_created h] references [/created/*/id] from copy response. *)
203
+
204
+
(** {2 References from Import} *)
205
+
206
+
val from_import_created : (import, _) handle -> id_source
207
+
(** [from_import_created h] references [/created/*/id] from import response. *)
208
+
209
+
(** {1 Chain Monad}
210
+
211
+
A monad for building JMAP requests with automatic call ID generation
212
+
and invocation collection. *)
213
+
214
+
type 'a t
215
+
(** A chain computation that produces ['a] (typically a handle). *)
216
+
217
+
val return : 'a -> 'a t
218
+
(** [return x] is a computation that produces [x] without adding any
219
+
method invocations. *)
220
+
221
+
val bind : 'a t -> ('a -> 'b t) -> 'b t
222
+
(** [bind m f] sequences computations, threading the chain state. *)
223
+
224
+
val map : ('a -> 'b) -> 'a t -> 'b t
225
+
(** [map f m] applies [f] to the result of [m]. *)
226
+
227
+
val both : 'a t -> 'b t -> ('a * 'b) t
228
+
(** [both a b] runs both computations, returning their results as a pair. *)
229
+
230
+
(** {2 Syntax} *)
231
+
232
+
val ( let* ) : 'a t -> ('a -> 'b t) -> 'b t
233
+
val ( let+ ) : 'a t -> ('a -> 'b) -> 'b t
234
+
val ( and* ) : 'a t -> 'b t -> ('a * 'b) t
235
+
val ( and+ ) : 'a t -> 'b t -> ('a * 'b) t
236
+
237
+
(** {1 Building Requests} *)
238
+
239
+
val build :
240
+
capabilities:string list ->
241
+
'a t ->
242
+
Jmap_proto.Request.t * 'a
243
+
(** [build ~capabilities chain] runs the chain computation, returning
244
+
the JMAP request and the final value (typically a handle for parsing). *)
245
+
246
+
val build_request :
247
+
capabilities:string list ->
248
+
'a t ->
249
+
Jmap_proto.Request.t
250
+
(** [build_request ~capabilities chain] is like {!build} but discards
251
+
the final value. *)
252
+
253
+
(** {1 Method Builders}
254
+
255
+
Each builder returns a handle wrapped in the chain monad.
256
+
Call IDs are assigned automatically based on invocation order. *)
257
+
258
+
(** {2 Email Methods} *)
259
+
260
+
val email_query :
261
+
account_id:Jmap_proto.Id.t ->
262
+
?filter:Jmap_proto.Mail_filter.email_filter ->
263
+
?sort:Jmap_proto.Filter.comparator list ->
264
+
?position:int64 ->
265
+
?anchor:Jmap_proto.Id.t ->
266
+
?anchor_offset:int64 ->
267
+
?limit:int64 ->
268
+
?calculate_total:bool ->
269
+
?collapse_threads:bool ->
270
+
unit ->
271
+
(query, Jmap_proto.Method.query_response) handle t
272
+
273
+
val email_get :
274
+
account_id:Jmap_proto.Id.t ->
275
+
?ids:id_source ->
276
+
?properties:string list ->
277
+
?body_properties:string list ->
278
+
?fetch_text_body_values:bool ->
279
+
?fetch_html_body_values:bool ->
280
+
?fetch_all_body_values:bool ->
281
+
?max_body_value_bytes:int64 ->
282
+
unit ->
283
+
(get, Jmap_proto.Email.t Jmap_proto.Method.get_response) handle t
284
+
285
+
val email_changes :
286
+
account_id:Jmap_proto.Id.t ->
287
+
since_state:string ->
288
+
?max_changes:int64 ->
289
+
unit ->
290
+
(changes, Jmap_proto.Method.changes_response) handle t
291
+
292
+
val email_query_changes :
293
+
account_id:Jmap_proto.Id.t ->
294
+
since_query_state:string ->
295
+
?filter:Jmap_proto.Mail_filter.email_filter ->
296
+
?sort:Jmap_proto.Filter.comparator list ->
297
+
?max_changes:int64 ->
298
+
?up_to_id:Jmap_proto.Id.t ->
299
+
?calculate_total:bool ->
300
+
unit ->
301
+
(query_changes, Jmap_proto.Method.query_changes_response) handle t
302
+
303
+
val email_set :
304
+
account_id:Jmap_proto.Id.t ->
305
+
?if_in_state:string ->
306
+
?create:(string * Jsont.Json.t) list ->
307
+
?update:(Jmap_proto.Id.t * Jsont.Json.t) list ->
308
+
?destroy:id_source ->
309
+
unit ->
310
+
(set, Jmap_proto.Email.t Jmap_proto.Method.set_response) handle t
311
+
(** Build an Email/set invocation.
312
+
313
+
[create] is a list of [(creation_id, email_object)] pairs where
314
+
[creation_id] is a client-chosen string (e.g., "draft1") and
315
+
[email_object] is the JSON representation of the email to create.
316
+
317
+
Use {!created_id_of_string} to reference created objects in later calls. *)
318
+
319
+
val email_copy :
320
+
from_account_id:Jmap_proto.Id.t ->
321
+
account_id:Jmap_proto.Id.t ->
322
+
?if_from_in_state:string ->
323
+
?if_in_state:string ->
324
+
?create:(Jmap_proto.Id.t * Jsont.Json.t) list ->
325
+
?on_success_destroy_original:bool ->
326
+
?destroy_from_if_in_state:string ->
327
+
unit ->
328
+
(copy, Jmap_proto.Email.t Jmap_proto.Method.copy_response) handle t
329
+
(** Build an Email/copy invocation.
330
+
331
+
[create] maps source email IDs to override objects. The source email
332
+
is copied to the target account with any overridden properties. *)
333
+
334
+
(** {2 Thread Methods} *)
335
+
336
+
val thread_get :
337
+
account_id:Jmap_proto.Id.t ->
338
+
?ids:id_source ->
339
+
unit ->
340
+
(get, Jmap_proto.Thread.t Jmap_proto.Method.get_response) handle t
341
+
342
+
val thread_changes :
343
+
account_id:Jmap_proto.Id.t ->
344
+
since_state:string ->
345
+
?max_changes:int64 ->
346
+
unit ->
347
+
(changes, Jmap_proto.Method.changes_response) handle t
348
+
349
+
(** {2 Mailbox Methods} *)
350
+
351
+
val mailbox_query :
352
+
account_id:Jmap_proto.Id.t ->
353
+
?filter:Jmap_proto.Mail_filter.mailbox_filter ->
354
+
?sort:Jmap_proto.Filter.comparator list ->
355
+
?position:int64 ->
356
+
?anchor:Jmap_proto.Id.t ->
357
+
?anchor_offset:int64 ->
358
+
?limit:int64 ->
359
+
?calculate_total:bool ->
360
+
unit ->
361
+
(query, Jmap_proto.Method.query_response) handle t
362
+
363
+
val mailbox_get :
364
+
account_id:Jmap_proto.Id.t ->
365
+
?ids:id_source ->
366
+
?properties:string list ->
367
+
unit ->
368
+
(get, Jmap_proto.Mailbox.t Jmap_proto.Method.get_response) handle t
369
+
370
+
val mailbox_changes :
371
+
account_id:Jmap_proto.Id.t ->
372
+
since_state:string ->
373
+
?max_changes:int64 ->
374
+
unit ->
375
+
(changes, Jmap_proto.Method.changes_response) handle t
376
+
377
+
val mailbox_query_changes :
378
+
account_id:Jmap_proto.Id.t ->
379
+
since_query_state:string ->
380
+
?filter:Jmap_proto.Mail_filter.mailbox_filter ->
381
+
?sort:Jmap_proto.Filter.comparator list ->
382
+
?max_changes:int64 ->
383
+
?up_to_id:Jmap_proto.Id.t ->
384
+
?calculate_total:bool ->
385
+
unit ->
386
+
(query_changes, Jmap_proto.Method.query_changes_response) handle t
387
+
388
+
val mailbox_set :
389
+
account_id:Jmap_proto.Id.t ->
390
+
?if_in_state:string ->
391
+
?create:(string * Jsont.Json.t) list ->
392
+
?update:(Jmap_proto.Id.t * Jsont.Json.t) list ->
393
+
?destroy:id_source ->
394
+
?on_destroy_remove_emails:bool ->
395
+
unit ->
396
+
(set, Jmap_proto.Mailbox.t Jmap_proto.Method.set_response) handle t
397
+
398
+
(** {2 Identity Methods} *)
399
+
400
+
val identity_get :
401
+
account_id:Jmap_proto.Id.t ->
402
+
?ids:id_source ->
403
+
?properties:string list ->
404
+
unit ->
405
+
(get, Jmap_proto.Identity.t Jmap_proto.Method.get_response) handle t
406
+
407
+
val identity_changes :
408
+
account_id:Jmap_proto.Id.t ->
409
+
since_state:string ->
410
+
?max_changes:int64 ->
411
+
unit ->
412
+
(changes, Jmap_proto.Method.changes_response) handle t
413
+
414
+
val identity_set :
415
+
account_id:Jmap_proto.Id.t ->
416
+
?if_in_state:string ->
417
+
?create:(string * Jsont.Json.t) list ->
418
+
?update:(Jmap_proto.Id.t * Jsont.Json.t) list ->
419
+
?destroy:id_source ->
420
+
unit ->
421
+
(set, Jmap_proto.Identity.t Jmap_proto.Method.set_response) handle t
422
+
423
+
(** {2 EmailSubmission Methods} *)
424
+
425
+
val email_submission_query :
426
+
account_id:Jmap_proto.Id.t ->
427
+
?filter:Jmap_proto.Mail_filter.submission_filter ->
428
+
?sort:Jmap_proto.Filter.comparator list ->
429
+
?position:int64 ->
430
+
?anchor:Jmap_proto.Id.t ->
431
+
?anchor_offset:int64 ->
432
+
?limit:int64 ->
433
+
?calculate_total:bool ->
434
+
unit ->
435
+
(query, Jmap_proto.Method.query_response) handle t
436
+
437
+
val email_submission_get :
438
+
account_id:Jmap_proto.Id.t ->
439
+
?ids:id_source ->
440
+
?properties:string list ->
441
+
unit ->
442
+
(get, Jmap_proto.Submission.t Jmap_proto.Method.get_response) handle t
443
+
444
+
val email_submission_changes :
445
+
account_id:Jmap_proto.Id.t ->
446
+
since_state:string ->
447
+
?max_changes:int64 ->
448
+
unit ->
449
+
(changes, Jmap_proto.Method.changes_response) handle t
450
+
451
+
val email_submission_query_changes :
452
+
account_id:Jmap_proto.Id.t ->
453
+
since_query_state:string ->
454
+
?filter:Jmap_proto.Mail_filter.submission_filter ->
455
+
?sort:Jmap_proto.Filter.comparator list ->
456
+
?max_changes:int64 ->
457
+
?up_to_id:Jmap_proto.Id.t ->
458
+
?calculate_total:bool ->
459
+
unit ->
460
+
(query_changes, Jmap_proto.Method.query_changes_response) handle t
461
+
462
+
val email_submission_set :
463
+
account_id:Jmap_proto.Id.t ->
464
+
?if_in_state:string ->
465
+
?create:(string * Jsont.Json.t) list ->
466
+
?update:(Jmap_proto.Id.t * Jsont.Json.t) list ->
467
+
?destroy:id_source ->
468
+
?on_success_update_email:(string * Jsont.Json.t) list ->
469
+
?on_success_destroy_email:string list ->
470
+
unit ->
471
+
(set, Jmap_proto.Submission.t Jmap_proto.Method.set_response) handle t
472
+
(** Build an EmailSubmission/set invocation.
473
+
474
+
[on_success_update_email] and [on_success_destroy_email] take creation IDs
475
+
(like ["#draft1"]) or real email IDs to update/destroy the email after
476
+
successful submission. *)
477
+
478
+
(** {2 SearchSnippet Methods} *)
479
+
480
+
val search_snippet_get :
481
+
account_id:Jmap_proto.Id.t ->
482
+
filter:Jmap_proto.Mail_filter.email_filter ->
483
+
email_ids:id_source ->
484
+
unit ->
485
+
(get, Jmap_proto.Search_snippet.t Jmap_proto.Method.get_response) handle t
486
+
(** Build a SearchSnippet/get invocation. Note that the filter must match
487
+
the filter used in the Email/query that produced the email IDs. *)
488
+
489
+
(** {2 VacationResponse Methods} *)
490
+
491
+
val vacation_response_get :
492
+
account_id:Jmap_proto.Id.t ->
493
+
?properties:string list ->
494
+
unit ->
495
+
(get, Jmap_proto.Vacation.t Jmap_proto.Method.get_response) handle t
496
+
497
+
val vacation_response_set :
498
+
account_id:Jmap_proto.Id.t ->
499
+
?if_in_state:string ->
500
+
update:Jsont.Json.t ->
501
+
unit ->
502
+
(set, Jmap_proto.Vacation.t Jmap_proto.Method.set_response) handle t
503
+
(** VacationResponse is a singleton - you can only update "singleton". *)
504
+
505
+
(** {1 Response Parsing} *)
506
+
507
+
val parse :
508
+
(_, 'resp) handle ->
509
+
Jmap_proto.Response.t ->
510
+
('resp, Jsont.Error.t) result
511
+
(** [parse handle response] extracts and parses the response for [handle].
512
+
513
+
The response type is determined by the handle's type parameter,
514
+
providing compile-time type safety. *)
515
+
516
+
val parse_exn : (_, 'resp) handle -> Jmap_proto.Response.t -> 'resp
517
+
(** [parse_exn handle response] is like {!parse} but raises on error. *)
518
+
519
+
(** {1 JSON Helpers}
520
+
521
+
Convenience functions for building JSON patch objects for /set methods. *)
522
+
523
+
val json_null : Jsont.Json.t
524
+
(** A JSON null value. Use to unset a property. *)
525
+
526
+
val json_bool : bool -> Jsont.Json.t
527
+
(** [json_bool b] creates a JSON boolean. *)
528
+
529
+
val json_string : string -> Jsont.Json.t
530
+
(** [json_string s] creates a JSON string. *)
531
+
532
+
val json_int : int64 -> Jsont.Json.t
533
+
(** [json_int n] creates a JSON number from an int64. *)
534
+
535
+
val json_obj : (string * Jsont.Json.t) list -> Jsont.Json.t
536
+
(** [json_obj fields] creates a JSON object from key-value pairs. *)
537
+
538
+
val json_array : Jsont.Json.t list -> Jsont.Json.t
539
+
(** [json_array items] creates a JSON array. *)
540
+
541
+
(** {1 Creation ID Helpers} *)
542
+
543
+
val fresh_create_id : unit -> 'a create_id t
544
+
(** [fresh_create_id ()] generates a fresh creation ID within the chain.
545
+
The ID is unique within the request. *)
546
+
547
+
(** {1 Low-Level Access}
548
+
549
+
For users who need direct access to the underlying invocation. *)
550
+
551
+
val raw_invocation :
552
+
name:string ->
553
+
arguments:Jsont.Json.t ->
554
+
(unit, Jsont.Json.t) handle t
555
+
(** [raw_invocation ~name ~arguments] adds a raw method invocation.
556
+
Use this for methods not yet supported by the high-level API. *)
+44
-91
lib/core/jmap.ml
+44
-91
lib/core/jmap.ml
···
130
130
let is_subscribed m = Proto.Mailbox.is_subscribed m
131
131
132
132
let role m =
133
-
let convert_role = function
134
-
| Proto.Mailbox.Inbox -> `Inbox
135
-
| Proto.Mailbox.Sent -> `Sent
136
-
| Proto.Mailbox.Drafts -> `Drafts
137
-
| Proto.Mailbox.Trash -> `Trash
138
-
| Proto.Mailbox.Junk -> `Junk
139
-
| Proto.Mailbox.Archive -> `Archive
140
-
| Proto.Mailbox.Flagged -> `Flagged
141
-
| Proto.Mailbox.Important -> `Important
142
-
| Proto.Mailbox.All -> `All
143
-
| Proto.Mailbox.Subscribed -> `Subscribed
144
-
| Proto.Mailbox.Other s -> `Custom s
133
+
(* Proto.Mailbox.role now returns polymorphic variants directly *)
134
+
let convert_role : Proto.Mailbox.role -> Role.t = function
135
+
| `Inbox -> `Inbox
136
+
| `Sent -> `Sent
137
+
| `Drafts -> `Drafts
138
+
| `Trash -> `Trash
139
+
| `Junk -> `Junk
140
+
| `Archive -> `Archive
141
+
| `Flagged -> `Flagged
142
+
| `Important -> `Important
143
+
| `All -> `All
144
+
| `Subscribed -> `Subscribed
145
+
| `Snoozed -> `Snoozed
146
+
| `Scheduled -> `Scheduled
147
+
| `Memos -> `Memos
148
+
| `Other s -> `Custom s
145
149
in
146
150
Option.map convert_role (Proto.Mailbox.role m)
147
151
···
212
216
213
217
(** Get active keywords as polymorphic variants. *)
214
218
let keywords e =
215
-
let kw_map = Proto.Email.keywords e in
216
-
List.filter_map (fun (k, v) ->
217
-
if v then Some (Keyword.of_string k) else None
218
-
) kw_map
219
+
match Proto.Email.keywords e with
220
+
| None -> []
221
+
| Some kw_map ->
222
+
List.filter_map (fun (k, v) ->
223
+
if v then Some (Keyword.of_string k) else None
224
+
) kw_map
219
225
220
226
(** Check if email has a specific keyword. *)
221
227
let has_keyword kw e =
222
228
let kw_str = Keyword.to_string kw in
223
-
let kw_map = Proto.Email.keywords e in
224
-
List.exists (fun (k, v) -> k = kw_str && v) kw_map
229
+
match Proto.Email.keywords e with
230
+
| None -> false
231
+
| Some kw_map -> List.exists (fun (k, v) -> k = kw_str && v) kw_map
225
232
226
233
let from e = Proto.Email.from e
227
234
let to_ e = Proto.Email.to_ e
···
347
354
module Mailbox_filter = struct
348
355
type condition = Proto.Mailbox.Filter_condition.t
349
356
350
-
let convert_role = function
351
-
| `Inbox -> Proto.Mailbox.Inbox
352
-
| `Sent -> Proto.Mailbox.Sent
353
-
| `Drafts -> Proto.Mailbox.Drafts
354
-
| `Trash -> Proto.Mailbox.Trash
355
-
| `Junk -> Proto.Mailbox.Junk
356
-
| `Archive -> Proto.Mailbox.Archive
357
-
| `Flagged -> Proto.Mailbox.Flagged
358
-
| `Important -> Proto.Mailbox.Important
359
-
| `All -> Proto.Mailbox.All
360
-
| `Subscribed -> Proto.Mailbox.Subscribed
361
-
| `Custom s -> Proto.Mailbox.Other s
357
+
let convert_role : Role.t -> Proto.Mailbox.role = function
358
+
| `Inbox -> `Inbox
359
+
| `Sent -> `Sent
360
+
| `Drafts -> `Drafts
361
+
| `Trash -> `Trash
362
+
| `Junk -> `Junk
363
+
| `Archive -> `Archive
364
+
| `Flagged -> `Flagged
365
+
| `Important -> `Important
366
+
| `All -> `All
367
+
| `Subscribed -> `Subscribed
368
+
| `Snoozed -> `Snoozed
369
+
| `Scheduled -> `Scheduled
370
+
| `Memos -> `Memos
371
+
| `Custom s -> `Other s
362
372
363
373
(** Create a mailbox filter condition.
364
374
···
428
438
val to_string : t -> (string, Error.t) result
429
439
end
430
440
431
-
(** {1 Private Interface} *)
441
+
(** {1 Request Chaining} *)
432
442
433
-
(** Private module for internal use by Jmap_eio.
443
+
(** JMAP method chaining with automatic result references.
434
444
435
-
This exposes the underlying Jsont codecs for serialization. *)
436
-
module Private = struct
437
-
module Session = struct
438
-
let jsont = Proto.Session.jsont
439
-
end
440
-
441
-
module Request = struct
442
-
let jsont = Proto.Request.jsont
443
-
end
444
-
445
-
module Response = struct
446
-
let jsont = Proto.Response.jsont
447
-
end
448
-
449
-
module Mailbox = struct
450
-
let jsont = Proto.Mailbox.jsont
451
-
end
452
-
453
-
module Email = struct
454
-
let jsont = Proto.Email.jsont
455
-
end
456
-
457
-
module Thread = struct
458
-
let jsont = Proto.Thread.jsont
459
-
end
460
-
461
-
module Identity = struct
462
-
let jsont = Proto.Identity.jsont
463
-
end
464
-
465
-
module Submission = struct
466
-
let jsont = Proto.Submission.jsont
467
-
end
468
-
469
-
module Vacation = struct
470
-
let jsont = Proto.Vacation.jsont
471
-
end
472
-
473
-
module Blob = struct
474
-
let upload_response_jsont = Proto.Blob.upload_response_jsont
475
-
end
476
-
477
-
module Method = struct
478
-
let get_response_jsont = Proto.Method.get_response_jsont
479
-
let query_response_jsont = Proto.Method.query_response_jsont
480
-
let changes_response_jsont = Proto.Method.changes_response_jsont
481
-
let set_response_jsont = Proto.Method.set_response_jsont
482
-
end
483
-
484
-
module Mail_filter = struct
485
-
let email_filter_jsont = Proto.Mail_filter.email_filter_jsont
486
-
let mailbox_filter_jsont = Proto.Mail_filter.mailbox_filter_jsont
487
-
let submission_filter_jsont = Proto.Mail_filter.submission_filter_jsont
488
-
end
489
-
490
-
module Filter = struct
491
-
let comparator_jsont = Proto.Filter.comparator_jsont
492
-
end
493
-
end
445
+
See {!Chain} for the full interface. *)
446
+
module Chain = Chain
+116
-102
lib/core/jmap.mli
+116
-102
lib/core/jmap.mli
···
99
99
Standard keywords are represented as polymorphic variants.
100
100
Custom keywords use [`Custom of string]. *)
101
101
module Keyword : sig
102
-
type t = [
102
+
(** RFC 8621 standard keywords *)
103
+
type standard = [
103
104
| `Seen
104
105
| `Flagged
105
106
| `Answered
···
108
109
| `Phishing
109
110
| `Junk
110
111
| `NotJunk
112
+
]
113
+
114
+
(** draft-ietf-mailmaint extended keywords *)
115
+
type extended = [
116
+
| `Notify
117
+
| `Muted
118
+
| `Followed
119
+
| `Memo
120
+
| `HasMemo
121
+
| `HasAttachment
122
+
| `HasNoAttachment
123
+
| `AutoSent
124
+
| `Unsubscribed
125
+
| `CanUnsubscribe
126
+
| `Imported
127
+
| `IsTrusted
128
+
| `MaskedEmail
129
+
| `New
130
+
]
131
+
132
+
(** Apple Mail flag color keywords *)
133
+
type flag_bits = [
134
+
| `MailFlagBit0
135
+
| `MailFlagBit1
136
+
| `MailFlagBit2
137
+
]
138
+
139
+
type t = [
140
+
| standard
141
+
| extended
142
+
| flag_bits
111
143
| `Custom of string
112
144
]
113
145
114
146
val of_string : string -> t
115
147
val to_string : t -> string
116
148
val pp : Format.formatter -> t -> unit
149
+
150
+
(** Apple Mail flag colors *)
151
+
type flag_color = [
152
+
| `Red
153
+
| `Orange
154
+
| `Yellow
155
+
| `Green
156
+
| `Blue
157
+
| `Purple
158
+
| `Gray
159
+
]
160
+
161
+
val flag_color_of_keywords : t list -> flag_color option
162
+
(** [flag_color_of_keywords keywords] extracts the flag color from a list
163
+
of keywords. Returns [None] for invalid bit combinations. *)
164
+
165
+
val flag_color_to_keywords : flag_color -> t list
166
+
(** [flag_color_to_keywords color] returns the keywords to set for the color. *)
117
167
end
118
168
119
169
(** Mailbox role type.
···
121
171
Standard roles are represented as polymorphic variants.
122
172
Custom roles use [`Custom of string]. *)
123
173
module Role : sig
124
-
type t = [
174
+
(** RFC 8621 standard roles *)
175
+
type standard = [
125
176
| `Inbox
126
177
| `Sent
127
178
| `Drafts
···
132
183
| `Important
133
184
| `All
134
185
| `Subscribed
186
+
]
187
+
188
+
(** draft-ietf-mailmaint extended roles *)
189
+
type extended = [
190
+
| `Snoozed
191
+
| `Scheduled
192
+
| `Memos
193
+
]
194
+
195
+
type t = [
196
+
| standard
197
+
| extended
135
198
| `Custom of string
136
199
]
137
200
···
204
267
val create : ?name:string -> string -> t
205
268
end
206
269
207
-
(** Email mailbox. *)
270
+
(** Email mailbox.
271
+
All accessors return option types since responses only include requested properties. *)
208
272
module Mailbox : sig
209
273
type t
210
274
211
-
val id : t -> Id.t
212
-
val name : t -> string
275
+
val id : t -> Id.t option
276
+
val name : t -> string option
213
277
val parent_id : t -> Id.t option
214
-
val sort_order : t -> int64
215
-
val total_emails : t -> int64
216
-
val unread_emails : t -> int64
217
-
val total_threads : t -> int64
218
-
val unread_threads : t -> int64
219
-
val is_subscribed : t -> bool
278
+
val sort_order : t -> int64 option
279
+
val total_emails : t -> int64 option
280
+
val unread_emails : t -> int64 option
281
+
val total_threads : t -> int64 option
282
+
val unread_threads : t -> int64 option
283
+
val is_subscribed : t -> bool option
220
284
val role : t -> Role.t option
221
285
222
286
(** Mailbox rights. *)
···
234
298
val may_submit : t -> bool
235
299
end
236
300
237
-
val my_rights : t -> Rights.t
301
+
val my_rights : t -> Rights.t option
238
302
end
239
303
240
-
(** Email thread. *)
304
+
(** Email thread.
305
+
All accessors return option types since responses only include requested properties. *)
241
306
module Thread : sig
242
307
type t
243
308
244
-
val id : t -> Id.t
245
-
val email_ids : t -> Id.t list
309
+
val id : t -> Id.t option
310
+
val email_ids : t -> Id.t list option
246
311
end
247
312
248
313
(** Email message. *)
···
268
333
val value_is_encoding_problem : value -> bool
269
334
end
270
335
336
+
(** All accessors return option types since responses only include requested properties. *)
271
337
type t
272
338
273
-
val id : t -> Id.t
274
-
val blob_id : t -> Id.t
275
-
val thread_id : t -> Id.t
276
-
val mailbox_ids : t -> (Id.t * bool) list
277
-
val size : t -> int64
278
-
val received_at : t -> Ptime.t
339
+
val id : t -> Id.t option
340
+
val blob_id : t -> Id.t option
341
+
val thread_id : t -> Id.t option
342
+
val mailbox_ids : t -> (Id.t * bool) list option
343
+
val size : t -> int64 option
344
+
val received_at : t -> Ptime.t option
279
345
val message_id : t -> string list option
280
346
val in_reply_to : t -> string list option
281
347
val references : t -> string list option
282
348
val subject : t -> string option
283
349
val sent_at : t -> Ptime.t option
284
-
val has_attachment : t -> bool
285
-
val preview : t -> string
350
+
val has_attachment : t -> bool option
351
+
val preview : t -> string option
286
352
287
-
(** Get active keywords as polymorphic variants. *)
353
+
(** Get active keywords as polymorphic variants.
354
+
Returns empty list if keywords property was not requested. *)
288
355
val keywords : t -> Keyword.t list
289
356
290
-
(** Check if email has a specific keyword. *)
357
+
(** Check if email has a specific keyword.
358
+
Returns false if keywords property was not requested. *)
291
359
val has_keyword : Keyword.t -> t -> bool
292
360
293
361
val from : t -> Email_address.t list option
···
303
371
val body_values : t -> (string * Body.value) list option
304
372
end
305
373
306
-
(** Email identity for sending. *)
374
+
(** Email identity for sending.
375
+
All accessors return option types since responses only include requested properties. *)
307
376
module Identity : sig
308
377
type t
309
378
310
-
val id : t -> Id.t
311
-
val name : t -> string
312
-
val email : t -> string
379
+
val id : t -> Id.t option
380
+
val name : t -> string option
381
+
val email : t -> string option
313
382
val reply_to : t -> Email_address.t list option
314
383
val bcc : t -> Email_address.t list option
315
-
val text_signature : t -> string
316
-
val html_signature : t -> string
317
-
val may_delete : t -> bool
384
+
val text_signature : t -> string option
385
+
val html_signature : t -> string option
386
+
val may_delete : t -> bool option
318
387
end
319
388
320
-
(** Email submission for outgoing mail. *)
389
+
(** Email submission for outgoing mail.
390
+
All accessors return option types since responses only include requested properties. *)
321
391
module Submission : sig
322
392
type t
323
393
324
-
val id : t -> Id.t
325
-
val identity_id : t -> Id.t
326
-
val email_id : t -> Id.t
327
-
val thread_id : t -> Id.t
328
-
val send_at : t -> Ptime.t
329
-
val undo_status : t -> Proto.Submission.undo_status
394
+
val id : t -> Id.t option
395
+
val identity_id : t -> Id.t option
396
+
val email_id : t -> Id.t option
397
+
val thread_id : t -> Id.t option
398
+
val send_at : t -> Ptime.t option
399
+
val undo_status : t -> Proto.Submission.undo_status option
330
400
val delivery_status : t -> (string * Proto.Submission.Delivery_status.t) list option
331
-
val dsn_blob_ids : t -> Id.t list
332
-
val mdn_blob_ids : t -> Id.t list
401
+
val dsn_blob_ids : t -> Id.t list option
402
+
val mdn_blob_ids : t -> Id.t list option
333
403
end
334
404
335
405
(** Vacation auto-response. *)
···
453
523
val to_string : t -> (string, Error.t) result
454
524
end
455
525
456
-
(** {1 Private Interface} *)
457
-
458
-
(** Private module for internal use by Jmap_eio.
459
-
460
-
This exposes the underlying Jsont codecs for serialization. *)
461
-
module Private : sig
462
-
module Session : sig
463
-
val jsont : Proto.Session.t Jsont.t
464
-
end
465
-
466
-
module Request : sig
467
-
val jsont : Proto.Request.t Jsont.t
468
-
end
469
-
470
-
module Response : sig
471
-
val jsont : Proto.Response.t Jsont.t
472
-
end
473
-
474
-
module Mailbox : sig
475
-
val jsont : Proto.Mailbox.t Jsont.t
476
-
end
477
-
478
-
module Email : sig
479
-
val jsont : Proto.Email.t Jsont.t
480
-
end
481
-
482
-
module Thread : sig
483
-
val jsont : Proto.Thread.t Jsont.t
484
-
end
485
-
486
-
module Identity : sig
487
-
val jsont : Proto.Identity.t Jsont.t
488
-
end
526
+
(** {1 Request Chaining} *)
489
527
490
-
module Submission : sig
491
-
val jsont : Proto.Submission.t Jsont.t
492
-
end
528
+
(** JMAP method chaining with automatic result references.
493
529
494
-
module Vacation : sig
495
-
val jsont : Proto.Vacation.t Jsont.t
496
-
end
497
-
498
-
module Blob : sig
499
-
val upload_response_jsont : Proto.Blob.upload_response Jsont.t
500
-
end
501
-
502
-
module Method : sig
503
-
val get_response_jsont : 'a Jsont.t -> 'a Proto.Method.get_response Jsont.t
504
-
val query_response_jsont : Proto.Method.query_response Jsont.t
505
-
val changes_response_jsont : Proto.Method.changes_response Jsont.t
506
-
val set_response_jsont : 'a Jsont.t -> 'a Proto.Method.set_response Jsont.t
507
-
end
508
-
509
-
module Mail_filter : sig
510
-
val email_filter_jsont : Proto.Mail_filter.email_filter Jsont.t
511
-
val mailbox_filter_jsont : Proto.Mail_filter.mailbox_filter Jsont.t
512
-
val submission_filter_jsont : Proto.Mail_filter.submission_filter Jsont.t
513
-
end
514
-
515
-
module Filter : sig
516
-
val comparator_jsont : Proto.Filter.comparator Jsont.t
517
-
end
518
-
end
530
+
This module provides a monadic interface for building JMAP requests
531
+
where method calls can reference results from previous calls. *)
532
+
module Chain = Chain
+132
-2
lib/core/jmap_types.ml
+132
-2
lib/core/jmap_types.ml
···
82
82
(** {1 Keyword Type} *)
83
83
84
84
module Keyword = struct
85
-
type t = [
85
+
(** RFC 8621 standard keywords *)
86
+
type standard = [
86
87
| `Seen
87
88
| `Flagged
88
89
| `Answered
···
91
92
| `Phishing
92
93
| `Junk
93
94
| `NotJunk
95
+
]
96
+
97
+
(** draft-ietf-mailmaint extended keywords *)
98
+
type extended = [
99
+
| `Notify
100
+
| `Muted
101
+
| `Followed
102
+
| `Memo
103
+
| `HasMemo
104
+
| `HasAttachment
105
+
| `HasNoAttachment
106
+
| `AutoSent
107
+
| `Unsubscribed
108
+
| `CanUnsubscribe
109
+
| `Imported
110
+
| `IsTrusted
111
+
| `MaskedEmail
112
+
| `New
113
+
]
114
+
115
+
(** Apple Mail flag color keywords *)
116
+
type flag_bits = [
117
+
| `MailFlagBit0
118
+
| `MailFlagBit1
119
+
| `MailFlagBit2
120
+
]
121
+
122
+
type t = [
123
+
| standard
124
+
| extended
125
+
| flag_bits
94
126
| `Custom of string
95
127
]
96
128
97
129
let of_string = function
130
+
(* RFC 8621 standard keywords *)
98
131
| "$seen" -> `Seen
99
132
| "$flagged" -> `Flagged
100
133
| "$answered" -> `Answered
···
103
136
| "$phishing" -> `Phishing
104
137
| "$junk" -> `Junk
105
138
| "$notjunk" -> `NotJunk
139
+
(* draft-ietf-mailmaint extended keywords *)
140
+
| "$notify" -> `Notify
141
+
| "$muted" -> `Muted
142
+
| "$followed" -> `Followed
143
+
| "$memo" -> `Memo
144
+
| "$hasmemo" -> `HasMemo
145
+
| "$hasattachment" -> `HasAttachment
146
+
| "$hasnoattachment" -> `HasNoAttachment
147
+
| "$autosent" -> `AutoSent
148
+
| "$unsubscribed" -> `Unsubscribed
149
+
| "$canunsubscribe" -> `CanUnsubscribe
150
+
| "$imported" -> `Imported
151
+
| "$istrusted" -> `IsTrusted
152
+
| "$maskedemail" -> `MaskedEmail
153
+
| "$new" -> `New
154
+
(* Apple Mail flag color keywords *)
155
+
| "$MailFlagBit0" -> `MailFlagBit0
156
+
| "$MailFlagBit1" -> `MailFlagBit1
157
+
| "$MailFlagBit2" -> `MailFlagBit2
106
158
| s -> `Custom s
107
159
108
160
let to_string = function
161
+
(* RFC 8621 standard keywords *)
109
162
| `Seen -> "$seen"
110
163
| `Flagged -> "$flagged"
111
164
| `Answered -> "$answered"
···
114
167
| `Phishing -> "$phishing"
115
168
| `Junk -> "$junk"
116
169
| `NotJunk -> "$notjunk"
170
+
(* draft-ietf-mailmaint extended keywords *)
171
+
| `Notify -> "$notify"
172
+
| `Muted -> "$muted"
173
+
| `Followed -> "$followed"
174
+
| `Memo -> "$memo"
175
+
| `HasMemo -> "$hasmemo"
176
+
| `HasAttachment -> "$hasattachment"
177
+
| `HasNoAttachment -> "$hasnoattachment"
178
+
| `AutoSent -> "$autosent"
179
+
| `Unsubscribed -> "$unsubscribed"
180
+
| `CanUnsubscribe -> "$canunsubscribe"
181
+
| `Imported -> "$imported"
182
+
| `IsTrusted -> "$istrusted"
183
+
| `MaskedEmail -> "$maskedemail"
184
+
| `New -> "$new"
185
+
(* Apple Mail flag color keywords *)
186
+
| `MailFlagBit0 -> "$MailFlagBit0"
187
+
| `MailFlagBit1 -> "$MailFlagBit1"
188
+
| `MailFlagBit2 -> "$MailFlagBit2"
117
189
| `Custom s -> s
118
190
119
191
let pp ppf k = Format.pp_print_string ppf (to_string k)
192
+
193
+
(** Apple Mail flag colors *)
194
+
type flag_color = [
195
+
| `Red
196
+
| `Orange
197
+
| `Yellow
198
+
| `Green
199
+
| `Blue
200
+
| `Purple
201
+
| `Gray
202
+
]
203
+
204
+
let flag_color_of_keywords (keywords : t list) : flag_color option =
205
+
let has k = List.mem k keywords in
206
+
let bit0 = has `MailFlagBit0 in
207
+
let bit1 = has `MailFlagBit1 in
208
+
let bit2 = has `MailFlagBit2 in
209
+
match (bit0, bit1, bit2) with
210
+
| (false, false, false) -> Some `Red
211
+
| (true, false, false) -> Some `Orange
212
+
| (false, true, false) -> Some `Yellow
213
+
| (true, true, true) -> Some `Green
214
+
| (false, false, true) -> Some `Blue
215
+
| (true, false, true) -> Some `Purple
216
+
| (false, true, true) -> Some `Gray
217
+
| (true, true, false) -> None
218
+
219
+
let flag_color_to_keywords : flag_color -> t list = function
220
+
| `Red -> []
221
+
| `Orange -> [`MailFlagBit0]
222
+
| `Yellow -> [`MailFlagBit1]
223
+
| `Green -> [`MailFlagBit0; `MailFlagBit1; `MailFlagBit2]
224
+
| `Blue -> [`MailFlagBit2]
225
+
| `Purple -> [`MailFlagBit0; `MailFlagBit2]
226
+
| `Gray -> [`MailFlagBit1; `MailFlagBit2]
120
227
end
121
228
122
229
(** {1 Mailbox Role Type} *)
123
230
124
231
module Role = struct
125
-
type t = [
232
+
(** RFC 8621 standard roles *)
233
+
type standard = [
126
234
| `Inbox
127
235
| `Sent
128
236
| `Drafts
···
133
241
| `Important
134
242
| `All
135
243
| `Subscribed
244
+
]
245
+
246
+
(** draft-ietf-mailmaint extended roles *)
247
+
type extended = [
248
+
| `Snoozed
249
+
| `Scheduled
250
+
| `Memos
251
+
]
252
+
253
+
type t = [
254
+
| standard
255
+
| extended
136
256
| `Custom of string
137
257
]
138
258
139
259
let of_string = function
260
+
(* RFC 8621 standard roles *)
140
261
| "inbox" -> `Inbox
141
262
| "sent" -> `Sent
142
263
| "drafts" -> `Drafts
···
147
268
| "important" -> `Important
148
269
| "all" -> `All
149
270
| "subscribed" -> `Subscribed
271
+
(* draft-ietf-mailmaint extended roles *)
272
+
| "snoozed" -> `Snoozed
273
+
| "scheduled" -> `Scheduled
274
+
| "memos" -> `Memos
150
275
| s -> `Custom s
151
276
152
277
let to_string = function
278
+
(* RFC 8621 standard roles *)
153
279
| `Inbox -> "inbox"
154
280
| `Sent -> "sent"
155
281
| `Drafts -> "drafts"
···
160
286
| `Important -> "important"
161
287
| `All -> "all"
162
288
| `Subscribed -> "subscribed"
289
+
(* draft-ietf-mailmaint extended roles *)
290
+
| `Snoozed -> "snoozed"
291
+
| `Scheduled -> "scheduled"
292
+
| `Memos -> "memos"
163
293
| `Custom s -> s
164
294
165
295
let pp ppf r = Format.pp_print_string ppf (to_string r)
+1
lib/dune
+1
lib/dune
+8
lib/js/dune
+8
lib/js/dune
+174
lib/js/jmap_brr.ml
+174
lib/js/jmap_brr.ml
···
1
+
(*---------------------------------------------------------------------------
2
+
Copyright (c) 2025 Anil Madhavapeddy. All rights reserved.
3
+
SPDX-License-Identifier: ISC
4
+
---------------------------------------------------------------------------*)
5
+
6
+
open Brr
7
+
open Fut.Syntax
8
+
9
+
type connection = {
10
+
session : Jmap.Proto.Session.t;
11
+
api_url : Jstr.t;
12
+
token : Jstr.t;
13
+
}
14
+
15
+
let session conn = conn.session
16
+
let api_url conn = conn.api_url
17
+
18
+
(* JSON logging callbacks *)
19
+
let on_request : (string -> string -> unit) option ref = ref None
20
+
let on_response : (string -> string -> unit) option ref = ref None
21
+
22
+
let set_request_logger f = on_request := Some f
23
+
let set_response_logger f = on_response := Some f
24
+
25
+
let log_request label json =
26
+
match !on_request with
27
+
| Some f -> f label json
28
+
| None -> ()
29
+
30
+
let log_response label json =
31
+
match !on_response with
32
+
| Some f -> f label json
33
+
| None -> ()
34
+
35
+
(* JSON encoding/decoding using jsont.brr *)
36
+
37
+
let encode_request req =
38
+
Jsont_brr.encode Jmap.Proto.Request.jsont req
39
+
40
+
let encode_response resp =
41
+
Jsont_brr.encode Jmap.Proto.Response.jsont resp
42
+
43
+
let encode_session session =
44
+
Jsont_brr.encode Jmap.Proto.Session.jsont session
45
+
46
+
let decode_json s =
47
+
match Brr.Json.decode s with
48
+
| Ok jv -> Ok (Obj.magic jv : Jsont.json) (* Jv.t and Jsont.json are compatible *)
49
+
| Error e -> Error e
50
+
51
+
let encode_json json =
52
+
Ok (Brr.Json.encode (Obj.magic json : Jv.t))
53
+
54
+
let pp_json ppf json =
55
+
match encode_json json with
56
+
| Ok s -> Format.pp_print_string ppf (Jstr.to_string s)
57
+
| Error _ -> Format.pp_print_string ppf "<json encoding error>"
58
+
59
+
(* HTTP helpers *)
60
+
61
+
let make_headers token =
62
+
Brr_io.Fetch.Headers.of_assoc [
63
+
Jstr.v "Authorization", Jstr.(v "Bearer " + token);
64
+
Jstr.v "Content-Type", Jstr.v "application/json";
65
+
Jstr.v "Accept", Jstr.v "application/json";
66
+
]
67
+
68
+
let fetch_json ~url ~meth ~headers ?body () =
69
+
Console.(log [str ">>> Request:"; str (Jstr.to_string meth); str (Jstr.to_string url)]);
70
+
(match body with
71
+
| Some b -> Console.(log [str ">>> Body:"; b])
72
+
| None -> Console.(log [str ">>> No body"]));
73
+
let init = Brr_io.Fetch.Request.init
74
+
~method':meth
75
+
~headers
76
+
?body
77
+
()
78
+
in
79
+
let req = Brr_io.Fetch.Request.v ~init url in
80
+
let* response = Brr_io.Fetch.request req in
81
+
match response with
82
+
| Error e ->
83
+
Console.(error [str "<<< Fetch error:"; e]);
84
+
Fut.return (Error e)
85
+
| Ok resp ->
86
+
let status = Brr_io.Fetch.Response.status resp in
87
+
Console.(log [str "<<< Response status:"; str (Jstr.of_int status)]);
88
+
if not (Brr_io.Fetch.Response.ok resp) then begin
89
+
let msg = Jstr.(v "HTTP error: " + of_int status) in
90
+
(* Try to get response body for error details *)
91
+
let body = Brr_io.Fetch.Response.as_body resp in
92
+
let* text = Brr_io.Fetch.Body.text body in
93
+
(match text with
94
+
| Ok t -> Console.(error [str "<<< Error body:"; str (Jstr.to_string t)])
95
+
| Error _ -> ());
96
+
Fut.return (Error (Jv.Error.v msg))
97
+
end else begin
98
+
let body = Brr_io.Fetch.Response.as_body resp in
99
+
let* text = Brr_io.Fetch.Body.text body in
100
+
match text with
101
+
| Error e ->
102
+
Console.(error [str "<<< Body read error:"; e]);
103
+
Fut.return (Error e)
104
+
| Ok text ->
105
+
Console.(log [str "<<< Response body:"; str (Jstr.to_string text)]);
106
+
Fut.return (Ok text)
107
+
end
108
+
109
+
(* Session establishment *)
110
+
111
+
let get_session ~url ~token =
112
+
Console.(log [str "get_session: token length ="; str (Jstr.of_int (Jstr.length token))]);
113
+
log_request "GET Session" (Printf.sprintf "{\"url\": \"%s\"}" (Jstr.to_string url));
114
+
let headers = make_headers token in
115
+
let* result = fetch_json ~url ~meth:(Jstr.v "GET") ~headers () in
116
+
match result with
117
+
| Error e -> Fut.return (Error e)
118
+
| Ok text ->
119
+
log_response "Session" (Jstr.to_string text);
120
+
match Jsont_brr.decode Jmap.Proto.Session.jsont text with
121
+
| Error e -> Fut.return (Error e)
122
+
| Ok session ->
123
+
let api_url = Jstr.v (Jmap.Proto.Session.api_url session) in
124
+
Fut.return (Ok { session; api_url; token })
125
+
126
+
(* Making requests *)
127
+
128
+
let request conn req =
129
+
let headers = make_headers conn.token in
130
+
match Jsont_brr.encode Jmap.Proto.Request.jsont req with
131
+
| Error e -> Fut.return (Error e)
132
+
| Ok body_str ->
133
+
log_request "JMAP Request" (Jstr.to_string body_str);
134
+
let body = Brr_io.Fetch.Body.of_jstr body_str in
135
+
let* result = fetch_json
136
+
~url:conn.api_url
137
+
~meth:(Jstr.v "POST")
138
+
~headers
139
+
~body
140
+
()
141
+
in
142
+
match result with
143
+
| Error e -> Fut.return (Error e)
144
+
| Ok text ->
145
+
log_response "JMAP Response" (Jstr.to_string text);
146
+
match Jsont_brr.decode Jmap.Proto.Response.jsont text with
147
+
| Error e -> Fut.return (Error e)
148
+
| Ok response -> Fut.return (Ok response)
149
+
150
+
let request_json conn json =
151
+
let headers = make_headers conn.token in
152
+
match encode_json json with
153
+
| Error e -> Fut.return (Error e)
154
+
| Ok body_str ->
155
+
let body = Brr_io.Fetch.Body.of_jstr body_str in
156
+
let* result = fetch_json
157
+
~url:conn.api_url
158
+
~meth:(Jstr.v "POST")
159
+
~headers
160
+
~body
161
+
()
162
+
in
163
+
match result with
164
+
| Error e -> Fut.return (Error e)
165
+
| Ok text ->
166
+
match decode_json text with
167
+
| Error e -> Fut.return (Error e)
168
+
| Ok json -> Fut.return (Ok json)
169
+
170
+
(* Toplevel support *)
171
+
172
+
let install_printers () =
173
+
(* In browser context, printers are registered via the OCaml console *)
174
+
Console.(log [str "JMAP printers installed"])
+107
lib/js/jmap_brr.mli
+107
lib/js/jmap_brr.mli
···
1
+
(*---------------------------------------------------------------------------
2
+
Copyright (c) 2025 Anil Madhavapeddy. All rights reserved.
3
+
SPDX-License-Identifier: ISC
4
+
---------------------------------------------------------------------------*)
5
+
6
+
(** JMAP client for browsers using Brr.
7
+
8
+
This module provides a JMAP client that runs in web browsers using
9
+
the Fetch API. It can be used with js_of_ocaml to build browser-based
10
+
email clients.
11
+
12
+
{2 Example}
13
+
14
+
{[
15
+
open Fut.Syntax
16
+
17
+
let main () =
18
+
let* session = Jmap_brr.get_session
19
+
~url:(Jstr.v "https://api.fastmail.com/jmap/session")
20
+
~token:(Jstr.v "your-api-token")
21
+
in
22
+
match session with
23
+
| Error e -> Brr.Console.(error [str "Session error:"; e]); Fut.return ()
24
+
| Ok session ->
25
+
Brr.Console.(log [str "Connected as:"; str (Jmap.Session.username session)]);
26
+
Fut.return ()
27
+
28
+
let () = ignore (main ())
29
+
]} *)
30
+
31
+
(** {1 Connection} *)
32
+
33
+
type connection
34
+
(** A JMAP connection to a server. *)
35
+
36
+
val session : connection -> Jmap.Proto.Session.t
37
+
(** [session conn] returns the session information. *)
38
+
39
+
val api_url : connection -> Jstr.t
40
+
(** [api_url conn] returns the API URL for requests. *)
41
+
42
+
(** {1 Session Establishment} *)
43
+
44
+
val get_session :
45
+
url:Jstr.t ->
46
+
token:Jstr.t ->
47
+
(connection, Jv.Error.t) result Fut.t
48
+
(** [get_session ~url ~token] establishes a JMAP session.
49
+
50
+
[url] is the session URL (e.g., ["https://api.fastmail.com/jmap/session"]).
51
+
[token] is the Bearer authentication token. *)
52
+
53
+
(** {1 Making Requests} *)
54
+
55
+
val request :
56
+
connection ->
57
+
Jmap.Proto.Request.t ->
58
+
(Jmap.Proto.Response.t, Jv.Error.t) result Fut.t
59
+
(** [request conn req] sends a JMAP request and returns the response. *)
60
+
61
+
val request_json :
62
+
connection ->
63
+
Jsont.json ->
64
+
(Jsont.json, Jv.Error.t) result Fut.t
65
+
(** [request_json conn json] sends a raw JSON request and returns the
66
+
JSON response. Useful for debugging or custom requests. *)
67
+
68
+
(** {1 JSON Encoding Utilities}
69
+
70
+
These functions help visualize how OCaml types map to JMAP JSON,
71
+
useful for the tutorial and debugging. *)
72
+
73
+
val encode_request : Jmap.Proto.Request.t -> (Jstr.t, Jv.Error.t) result
74
+
(** [encode_request req] encodes a request to JSON string. *)
75
+
76
+
val encode_response : Jmap.Proto.Response.t -> (Jstr.t, Jv.Error.t) result
77
+
(** [encode_response resp] encodes a response to JSON string. *)
78
+
79
+
val encode_session : Jmap.Proto.Session.t -> (Jstr.t, Jv.Error.t) result
80
+
(** [encode_session session] encodes a session to JSON string. *)
81
+
82
+
val decode_json : Jstr.t -> (Jsont.json, Jv.Error.t) result
83
+
(** [decode_json s] parses a JSON string to a Jsont.json value. *)
84
+
85
+
val encode_json : Jsont.json -> (Jstr.t, Jv.Error.t) result
86
+
(** [encode_json json] encodes a Jsont.json value to a string. *)
87
+
88
+
val pp_json : Format.formatter -> Jsont.json -> unit
89
+
(** [pp_json ppf json] pretty-prints JSON. For toplevel use. *)
90
+
91
+
(** {1 Protocol Logging} *)
92
+
93
+
val set_request_logger : (string -> string -> unit) -> unit
94
+
(** [set_request_logger f] registers a callback [f label json] that will be
95
+
called with each outgoing JMAP request. Useful for debugging and
96
+
educational displays. *)
97
+
98
+
val set_response_logger : (string -> string -> unit) -> unit
99
+
(** [set_response_logger f] registers a callback [f label json] that will be
100
+
called with each incoming JMAP response. Useful for debugging and
101
+
educational displays. *)
102
+
103
+
(** {1 Toplevel Support} *)
104
+
105
+
val install_printers : unit -> unit
106
+
(** [install_printers ()] installs toplevel pretty printers for JMAP types.
107
+
This is useful when using the OCaml console in the browser. *)
+359
-55
lib/mail/mail_email.ml
+359
-55
lib/mail/mail_email.ml
···
4
4
---------------------------------------------------------------------------*)
5
5
6
6
module Keyword = struct
7
+
(* RFC 8621 Standard Keywords *)
7
8
let draft = "$draft"
8
9
let seen = "$seen"
9
10
let flagged = "$flagged"
···
12
13
let phishing = "$phishing"
13
14
let junk = "$junk"
14
15
let not_junk = "$notjunk"
16
+
17
+
(* draft-ietf-mailmaint Extended Keywords *)
18
+
let notify = "$notify"
19
+
let muted = "$muted"
20
+
let followed = "$followed"
21
+
let memo = "$memo"
22
+
let has_memo = "$hasmemo"
23
+
let has_attachment = "$hasattachment"
24
+
let has_no_attachment = "$hasnoattachment"
25
+
let auto_sent = "$autosent"
26
+
let unsubscribed = "$unsubscribed"
27
+
let can_unsubscribe = "$canunsubscribe"
28
+
let imported = "$imported"
29
+
let is_trusted = "$istrusted"
30
+
let masked_email = "$maskedemail"
31
+
let new_ = "$new"
32
+
33
+
(* Apple Mail Flag Color Keywords *)
34
+
let mail_flag_bit0 = "$MailFlagBit0"
35
+
let mail_flag_bit1 = "$MailFlagBit1"
36
+
let mail_flag_bit2 = "$MailFlagBit2"
37
+
38
+
type flag_color = [
39
+
| `Red
40
+
| `Orange
41
+
| `Yellow
42
+
| `Green
43
+
| `Blue
44
+
| `Purple
45
+
| `Gray
46
+
]
47
+
48
+
let flag_color_to_keywords = function
49
+
| `Red -> []
50
+
| `Orange -> [mail_flag_bit0]
51
+
| `Yellow -> [mail_flag_bit1]
52
+
| `Green -> [mail_flag_bit0; mail_flag_bit1; mail_flag_bit2]
53
+
| `Blue -> [mail_flag_bit2]
54
+
| `Purple -> [mail_flag_bit0; mail_flag_bit2]
55
+
| `Gray -> [mail_flag_bit1; mail_flag_bit2]
56
+
57
+
let flag_color_of_keywords keywords =
58
+
let has k = List.mem k keywords in
59
+
let bit0 = has mail_flag_bit0 in
60
+
let bit1 = has mail_flag_bit1 in
61
+
let bit2 = has mail_flag_bit2 in
62
+
match (bit0, bit1, bit2) with
63
+
| (false, false, false) -> Some `Red
64
+
| (true, false, false) -> Some `Orange
65
+
| (false, true, false) -> Some `Yellow
66
+
| (true, true, true) -> Some `Green
67
+
| (false, false, true) -> Some `Blue
68
+
| (true, false, true) -> Some `Purple
69
+
| (false, true, true) -> Some `Gray
70
+
| (true, true, false) -> None
15
71
end
16
72
73
+
(* Email property types *)
74
+
75
+
type metadata_property = [
76
+
| `Id
77
+
| `Blob_id
78
+
| `Thread_id
79
+
| `Mailbox_ids
80
+
| `Keywords
81
+
| `Size
82
+
| `Received_at
83
+
]
84
+
85
+
type header_convenience_property = [
86
+
| `Message_id
87
+
| `In_reply_to
88
+
| `References
89
+
| `Sender
90
+
| `From
91
+
| `To
92
+
| `Cc
93
+
| `Bcc
94
+
| `Reply_to
95
+
| `Subject
96
+
| `Sent_at
97
+
| `Headers
98
+
]
99
+
100
+
type body_property = [
101
+
| `Body_structure
102
+
| `Body_values
103
+
| `Text_body
104
+
| `Html_body
105
+
| `Attachments
106
+
| `Has_attachment
107
+
| `Preview
108
+
]
109
+
110
+
type standard_property = [
111
+
| metadata_property
112
+
| header_convenience_property
113
+
| body_property
114
+
]
115
+
116
+
type header_property = [ `Header of Mail_header.header_property ]
117
+
118
+
type property = [ standard_property | header_property ]
119
+
120
+
let standard_property_to_string : [< standard_property ] -> string = function
121
+
| `Id -> "id"
122
+
| `Blob_id -> "blobId"
123
+
| `Thread_id -> "threadId"
124
+
| `Mailbox_ids -> "mailboxIds"
125
+
| `Keywords -> "keywords"
126
+
| `Size -> "size"
127
+
| `Received_at -> "receivedAt"
128
+
| `Message_id -> "messageId"
129
+
| `In_reply_to -> "inReplyTo"
130
+
| `References -> "references"
131
+
| `Sender -> "sender"
132
+
| `From -> "from"
133
+
| `To -> "to"
134
+
| `Cc -> "cc"
135
+
| `Bcc -> "bcc"
136
+
| `Reply_to -> "replyTo"
137
+
| `Subject -> "subject"
138
+
| `Sent_at -> "sentAt"
139
+
| `Headers -> "headers"
140
+
| `Body_structure -> "bodyStructure"
141
+
| `Body_values -> "bodyValues"
142
+
| `Text_body -> "textBody"
143
+
| `Html_body -> "htmlBody"
144
+
| `Attachments -> "attachments"
145
+
| `Has_attachment -> "hasAttachment"
146
+
| `Preview -> "preview"
147
+
148
+
let property_to_string : [< property ] -> string = function
149
+
| `Header hp -> Mail_header.header_property_to_string hp
150
+
| #standard_property as p -> standard_property_to_string p
151
+
152
+
let standard_property_of_string s : standard_property option =
153
+
match s with
154
+
| "id" -> Some `Id
155
+
| "blobId" -> Some `Blob_id
156
+
| "threadId" -> Some `Thread_id
157
+
| "mailboxIds" -> Some `Mailbox_ids
158
+
| "keywords" -> Some `Keywords
159
+
| "size" -> Some `Size
160
+
| "receivedAt" -> Some `Received_at
161
+
| "messageId" -> Some `Message_id
162
+
| "inReplyTo" -> Some `In_reply_to
163
+
| "references" -> Some `References
164
+
| "sender" -> Some `Sender
165
+
| "from" -> Some `From
166
+
| "to" -> Some `To
167
+
| "cc" -> Some `Cc
168
+
| "bcc" -> Some `Bcc
169
+
| "replyTo" -> Some `Reply_to
170
+
| "subject" -> Some `Subject
171
+
| "sentAt" -> Some `Sent_at
172
+
| "headers" -> Some `Headers
173
+
| "bodyStructure" -> Some `Body_structure
174
+
| "bodyValues" -> Some `Body_values
175
+
| "textBody" -> Some `Text_body
176
+
| "htmlBody" -> Some `Html_body
177
+
| "attachments" -> Some `Attachments
178
+
| "hasAttachment" -> Some `Has_attachment
179
+
| "preview" -> Some `Preview
180
+
| _ -> None
181
+
182
+
let property_of_string s : property option =
183
+
match standard_property_of_string s with
184
+
| Some p -> Some (p :> property)
185
+
| None ->
186
+
match Mail_header.header_property_of_string s with
187
+
| Some hp -> Some (`Header hp)
188
+
| None -> None
189
+
190
+
(* Body part properties *)
191
+
192
+
type body_part_property = [
193
+
| `Part_id
194
+
| `Blob_id
195
+
| `Size
196
+
| `Part_headers
197
+
| `Name
198
+
| `Type
199
+
| `Charset
200
+
| `Disposition
201
+
| `Cid
202
+
| `Language
203
+
| `Location
204
+
| `Sub_parts
205
+
]
206
+
207
+
let body_part_property_to_string : [< body_part_property ] -> string = function
208
+
| `Part_id -> "partId"
209
+
| `Blob_id -> "blobId"
210
+
| `Size -> "size"
211
+
| `Part_headers -> "headers"
212
+
| `Name -> "name"
213
+
| `Type -> "type"
214
+
| `Charset -> "charset"
215
+
| `Disposition -> "disposition"
216
+
| `Cid -> "cid"
217
+
| `Language -> "language"
218
+
| `Location -> "location"
219
+
| `Sub_parts -> "subParts"
220
+
221
+
let body_part_property_of_string s : body_part_property option =
222
+
match s with
223
+
| "partId" -> Some `Part_id
224
+
| "blobId" -> Some `Blob_id
225
+
| "size" -> Some `Size
226
+
| "headers" -> Some `Part_headers
227
+
| "name" -> Some `Name
228
+
| "type" -> Some `Type
229
+
| "charset" -> Some `Charset
230
+
| "disposition" -> Some `Disposition
231
+
| "cid" -> Some `Cid
232
+
| "language" -> Some `Language
233
+
| "location" -> Some `Location
234
+
| "subParts" -> Some `Sub_parts
235
+
| _ -> None
236
+
237
+
(* Email type with optional fields *)
238
+
17
239
type t = {
18
-
id : Proto_id.t;
19
-
blob_id : Proto_id.t;
20
-
thread_id : Proto_id.t;
21
-
size : int64;
22
-
received_at : Ptime.t;
23
-
mailbox_ids : (Proto_id.t * bool) list;
24
-
keywords : (string * bool) list;
240
+
id : Proto_id.t option;
241
+
blob_id : Proto_id.t option;
242
+
thread_id : Proto_id.t option;
243
+
size : int64 option;
244
+
received_at : Ptime.t option;
245
+
mailbox_ids : (Proto_id.t * bool) list option;
246
+
keywords : (string * bool) list option;
25
247
message_id : string list option;
26
248
in_reply_to : string list option;
27
249
references : string list option;
···
39
261
text_body : Mail_body.Part.t list option;
40
262
html_body : Mail_body.Part.t list option;
41
263
attachments : Mail_body.Part.t list option;
42
-
has_attachment : bool;
43
-
preview : string;
264
+
has_attachment : bool option;
265
+
preview : string option;
266
+
dynamic_headers : (string * Jsont.json) list;
44
267
}
45
268
46
269
let id t = t.id
···
69
292
let attachments t = t.attachments
70
293
let has_attachment t = t.has_attachment
71
294
let preview t = t.preview
295
+
let dynamic_headers_raw t = t.dynamic_headers
296
+
297
+
(* Parse header property name to determine form and :all flag *)
298
+
let parse_header_prop name =
299
+
if not (String.length name > 7 && String.sub name 0 7 = "header:") then
300
+
None
301
+
else
302
+
let rest = String.sub name 7 (String.length name - 7) in
303
+
let parts = String.split_on_char ':' rest in
304
+
match parts with
305
+
| [] -> None
306
+
| [_name] -> Some (`Raw, false)
307
+
| [_name; second] ->
308
+
if second = "all" then Some (`Raw, true)
309
+
else (
310
+
match Mail_header.form_of_string second with
311
+
| Some form -> Some (form, false)
312
+
| None -> None
313
+
)
314
+
| [_name; form_str; "all"] ->
315
+
(match Mail_header.form_of_string form_str with
316
+
| Some form -> Some (form, true)
317
+
| None -> None)
318
+
| _ -> None
319
+
320
+
(* Decode a raw JSON header value into typed header_value *)
321
+
let decode_header_value prop_name json =
322
+
match parse_header_prop prop_name with
323
+
| None -> None
324
+
| Some (form, all) ->
325
+
let jsont = Mail_header.header_value_jsont ~form ~all in
326
+
match Jsont.Json.decode' jsont json with
327
+
| Ok v -> Some v
328
+
| Error _ -> None
329
+
330
+
let get_header t key =
331
+
match List.assoc_opt key t.dynamic_headers with
332
+
| None -> None
333
+
| Some json -> decode_header_value key json
334
+
335
+
let get_header_string t key =
336
+
match get_header t key with
337
+
| Some (Mail_header.String_single s) -> s
338
+
| _ -> None
339
+
340
+
let get_header_addresses t key =
341
+
match get_header t key with
342
+
| Some (Mail_header.Addresses_single addrs) -> addrs
343
+
| _ -> None
72
344
73
345
let make id blob_id thread_id size received_at mailbox_ids keywords
74
346
message_id in_reply_to references sender from to_ cc bcc reply_to
75
347
subject sent_at headers body_structure body_values text_body html_body
76
-
attachments has_attachment preview =
348
+
attachments has_attachment preview dynamic_headers =
77
349
{ id; blob_id; thread_id; size; received_at; mailbox_ids; keywords;
78
350
message_id; in_reply_to; references; sender; from; to_; cc; bcc;
79
351
reply_to; subject; sent_at; headers; body_structure; body_values;
80
-
text_body; html_body; attachments; has_attachment; preview }
352
+
text_body; html_body; attachments; has_attachment; preview; dynamic_headers }
353
+
354
+
(* Helper: null-safe list decoder - treats null as empty list.
355
+
This allows fields that may be null or array to decode successfully. *)
356
+
let null_safe_list inner_jsont =
357
+
Jsont.map
358
+
~dec:(function None -> [] | Some l -> l)
359
+
~enc:(fun l -> Some l)
360
+
(Jsont.option (Jsont.list inner_jsont))
361
+
362
+
module String_map = Map.Make(String)
363
+
364
+
(* Filter unknown members to only keep header:* properties *)
365
+
let filter_header_props (unknown : Jsont.json String_map.t) : (string * Jsont.json) list =
366
+
String_map.to_seq unknown
367
+
|> Seq.filter (fun (k, _) -> String.length k > 7 && String.sub k 0 7 = "header:")
368
+
|> List.of_seq
81
369
82
370
let jsont =
83
371
let kind = "Email" in
84
372
let body_values_jsont = Proto_json_map.of_string Mail_body.Value.jsont in
85
-
(* subject can be null per RFC 8621 Section 4.1.1 *)
86
-
let nullable_string = Jsont.(option string) in
87
-
Jsont.Object.map ~kind make
88
-
|> Jsont.Object.mem "id" Proto_id.jsont ~enc:id
89
-
|> Jsont.Object.mem "blobId" Proto_id.jsont ~enc:blob_id
90
-
|> Jsont.Object.mem "threadId" Proto_id.jsont ~enc:thread_id
91
-
|> Jsont.Object.mem "size" Proto_int53.Unsigned.jsont ~enc:size
92
-
|> Jsont.Object.mem "receivedAt" Proto_date.Utc.jsont ~enc:received_at
93
-
|> Jsont.Object.mem "mailboxIds" Proto_json_map.id_to_bool ~enc:mailbox_ids
94
-
|> Jsont.Object.mem "keywords" Proto_json_map.string_to_bool ~dec_absent:[] ~enc:keywords
95
-
(* Header fields can be absent or null per RFC 8621 *)
96
-
|> Jsont.Object.mem "messageId" Jsont.(option (list string))
97
-
~dec_absent:None ~enc_omit:Option.is_none ~enc:message_id
98
-
|> Jsont.Object.mem "inReplyTo" Jsont.(option (list string))
99
-
~dec_absent:None ~enc_omit:Option.is_none ~enc:in_reply_to
100
-
|> Jsont.Object.mem "references" Jsont.(option (list string))
101
-
~dec_absent:None ~enc_omit:Option.is_none ~enc:references
102
-
|> Jsont.Object.mem "sender" Jsont.(option (list Mail_address.jsont))
103
-
~dec_absent:None ~enc_omit:Option.is_none ~enc:sender
104
-
|> Jsont.Object.mem "from" Jsont.(option (list Mail_address.jsont))
105
-
~dec_absent:None ~enc_omit:Option.is_none ~enc:from
106
-
|> Jsont.Object.mem "to" Jsont.(option (list Mail_address.jsont))
107
-
~dec_absent:None ~enc_omit:Option.is_none ~enc:to_
108
-
|> Jsont.Object.mem "cc" Jsont.(option (list Mail_address.jsont))
109
-
~dec_absent:None ~enc_omit:Option.is_none ~enc:cc
110
-
|> Jsont.Object.mem "bcc" Jsont.(option (list Mail_address.jsont))
111
-
~dec_absent:None ~enc_omit:Option.is_none ~enc:bcc
112
-
|> Jsont.Object.mem "replyTo" Jsont.(option (list Mail_address.jsont))
113
-
~dec_absent:None ~enc_omit:Option.is_none ~enc:reply_to
114
-
|> Jsont.Object.mem "subject" nullable_string
115
-
~dec_absent:None ~enc_omit:Option.is_none ~enc:subject
373
+
(* Use null_safe_list for address fields that can be null *)
374
+
let addr_list = null_safe_list Mail_address.jsont in
375
+
let str_list = null_safe_list Jsont.string in
376
+
let part_list = null_safe_list Mail_body.Part.jsont in
377
+
let hdr_list = null_safe_list Mail_header.jsont in
378
+
Jsont.Object.map ~kind (fun id blob_id thread_id size received_at mailbox_ids keywords
379
+
message_id in_reply_to references sender from to_ cc bcc reply_to
380
+
subject sent_at headers body_structure body_values text_body html_body
381
+
attachments has_attachment preview unknown ->
382
+
let dynamic_headers = filter_header_props unknown in
383
+
make id blob_id thread_id size received_at mailbox_ids keywords
384
+
message_id in_reply_to references sender from to_ cc bcc reply_to
385
+
subject sent_at headers body_structure body_values text_body html_body
386
+
attachments has_attachment preview dynamic_headers)
387
+
|> Jsont.Object.opt_mem "id" Proto_id.jsont ~enc:id
388
+
|> Jsont.Object.opt_mem "blobId" Proto_id.jsont ~enc:blob_id
389
+
|> Jsont.Object.opt_mem "threadId" Proto_id.jsont ~enc:thread_id
390
+
|> Jsont.Object.opt_mem "size" Proto_int53.Unsigned.jsont ~enc:size
391
+
|> Jsont.Object.opt_mem "receivedAt" Proto_date.Utc.jsont ~enc:received_at
392
+
|> Jsont.Object.opt_mem "mailboxIds" Proto_json_map.id_to_bool ~enc:mailbox_ids
393
+
|> Jsont.Object.opt_mem "keywords" Proto_json_map.string_to_bool ~enc:keywords
394
+
|> Jsont.Object.opt_mem "messageId" str_list ~enc:message_id
395
+
|> Jsont.Object.opt_mem "inReplyTo" str_list ~enc:in_reply_to
396
+
|> Jsont.Object.opt_mem "references" str_list ~enc:references
397
+
|> Jsont.Object.opt_mem "sender" addr_list ~enc:sender
398
+
|> Jsont.Object.opt_mem "from" addr_list ~enc:from
399
+
|> Jsont.Object.opt_mem "to" addr_list ~enc:to_
400
+
|> Jsont.Object.opt_mem "cc" addr_list ~enc:cc
401
+
|> Jsont.Object.opt_mem "bcc" addr_list ~enc:bcc
402
+
|> Jsont.Object.opt_mem "replyTo" addr_list ~enc:reply_to
403
+
|> Jsont.Object.opt_mem "subject" Jsont.string ~enc:subject
116
404
|> Jsont.Object.opt_mem "sentAt" Proto_date.Rfc3339.jsont ~enc:sent_at
117
-
|> Jsont.Object.opt_mem "headers" (Jsont.list Mail_header.jsont) ~enc:headers
405
+
|> Jsont.Object.opt_mem "headers" hdr_list ~enc:headers
118
406
|> Jsont.Object.opt_mem "bodyStructure" Mail_body.Part.jsont ~enc:body_structure
119
407
|> Jsont.Object.opt_mem "bodyValues" body_values_jsont ~enc:body_values
120
-
|> Jsont.Object.opt_mem "textBody" (Jsont.list Mail_body.Part.jsont) ~enc:text_body
121
-
|> Jsont.Object.opt_mem "htmlBody" (Jsont.list Mail_body.Part.jsont) ~enc:html_body
122
-
|> Jsont.Object.opt_mem "attachments" (Jsont.list Mail_body.Part.jsont) ~enc:attachments
123
-
|> Jsont.Object.mem "hasAttachment" Jsont.bool ~dec_absent:false ~enc:has_attachment
124
-
|> Jsont.Object.mem "preview" Jsont.string ~dec_absent:"" ~enc:preview
408
+
|> Jsont.Object.opt_mem "textBody" part_list ~enc:text_body
409
+
|> Jsont.Object.opt_mem "htmlBody" part_list ~enc:html_body
410
+
|> Jsont.Object.opt_mem "attachments" part_list ~enc:attachments
411
+
|> Jsont.Object.opt_mem "hasAttachment" Jsont.bool ~enc:has_attachment
412
+
|> Jsont.Object.opt_mem "preview" Jsont.string ~enc:preview
413
+
|> Jsont.Object.keep_unknown
414
+
(Jsont.Object.Mems.string_map Jsont.json)
415
+
~enc:(fun t -> String_map.of_list t.dynamic_headers)
125
416
|> Jsont.Object.finish
126
417
127
418
module Filter_condition = struct
···
157
448
none_in_thread_have_keyword; has_keyword; not_keyword; has_attachment;
158
449
text; from; to_; cc; bcc; subject; body; header }
159
450
160
-
(* Header filter is encoded as [name] or [name, value] array *)
161
451
let header_jsont =
162
452
let kind = "HeaderFilter" in
163
453
let dec json =
···
203
493
end
204
494
205
495
type get_args_extra = {
206
-
body_properties : string list option;
496
+
body_properties : body_part_property list option;
207
497
fetch_text_body_values : bool;
208
498
fetch_html_body_values : bool;
209
499
fetch_all_body_values : bool;
210
500
max_body_value_bytes : int64 option;
211
501
}
212
502
213
-
let get_args_extra_make body_properties fetch_text_body_values
214
-
fetch_html_body_values fetch_all_body_values max_body_value_bytes =
503
+
let get_args_extra ?body_properties ?(fetch_text_body_values=false)
504
+
?(fetch_html_body_values=false) ?(fetch_all_body_values=false)
505
+
?max_body_value_bytes () =
215
506
{ body_properties; fetch_text_body_values; fetch_html_body_values;
216
507
fetch_all_body_values; max_body_value_bytes }
217
508
509
+
let body_part_property_list_jsont =
510
+
Jsont.list (Jsont.map ~kind:"body_part_property"
511
+
~dec:(fun s -> match body_part_property_of_string s with
512
+
| Some p -> p
513
+
| None -> Jsont.Error.msgf Jsont.Meta.none "Unknown body property: %s" s)
514
+
~enc:body_part_property_to_string
515
+
Jsont.string)
516
+
218
517
let get_args_extra_jsont =
219
518
let kind = "Email/get extra args" in
220
-
Jsont.Object.map ~kind get_args_extra_make
221
-
|> Jsont.Object.opt_mem "bodyProperties" (Jsont.list Jsont.string) ~enc:(fun a -> a.body_properties)
519
+
Jsont.Object.map ~kind (fun body_properties fetch_text_body_values
520
+
fetch_html_body_values fetch_all_body_values max_body_value_bytes ->
521
+
{ body_properties; fetch_text_body_values; fetch_html_body_values;
522
+
fetch_all_body_values; max_body_value_bytes })
523
+
|> Jsont.Object.opt_mem "bodyProperties" body_part_property_list_jsont
524
+
~enc:(fun a -> a.body_properties)
222
525
|> Jsont.Object.mem "fetchTextBodyValues" Jsont.bool ~dec_absent:false
223
526
~enc:(fun a -> a.fetch_text_body_values) ~enc_omit:(fun b -> not b)
224
527
|> Jsont.Object.mem "fetchHTMLBodyValues" Jsont.bool ~dec_absent:false
225
528
~enc:(fun a -> a.fetch_html_body_values) ~enc_omit:(fun b -> not b)
226
529
|> Jsont.Object.mem "fetchAllBodyValues" Jsont.bool ~dec_absent:false
227
530
~enc:(fun a -> a.fetch_all_body_values) ~enc_omit:(fun b -> not b)
228
-
|> Jsont.Object.opt_mem "maxBodyValueBytes" Proto_int53.Unsigned.jsont ~enc:(fun a -> a.max_body_value_bytes)
531
+
|> Jsont.Object.opt_mem "maxBodyValueBytes" Proto_int53.Unsigned.jsont
532
+
~enc:(fun a -> a.max_body_value_bytes)
229
533
|> Jsont.Object.finish
+282
-29
lib/mail/mail_email.mli
+282
-29
lib/mail/mail_email.mli
···
9
9
10
10
(** {1 Standard Keywords} *)
11
11
12
-
(** Standard email keywords per RFC 8621. *)
12
+
(** Standard email keywords per RFC 8621 and draft-ietf-mailmaint.
13
+
14
+
Keywords are stored as strings in JMAP, but these constants provide
15
+
type-safe access to standard keywords. *)
13
16
module Keyword : sig
17
+
18
+
(** {2 RFC 8621 Standard Keywords} *)
19
+
14
20
val draft : string
15
-
(** ["$draft"] *)
21
+
(** ["$draft"] - The Email is a draft the user is composing. *)
16
22
17
23
val seen : string
18
-
(** ["$seen"] *)
24
+
(** ["$seen"] - The Email has been read. *)
19
25
20
26
val flagged : string
21
-
(** ["$flagged"] *)
27
+
(** ["$flagged"] - The Email has been flagged for urgent/special attention. *)
22
28
23
29
val answered : string
24
-
(** ["$answered"] *)
30
+
(** ["$answered"] - The Email has been replied to. *)
25
31
26
32
val forwarded : string
27
-
(** ["$forwarded"] *)
33
+
(** ["$forwarded"] - The Email has been forwarded. *)
28
34
29
35
val phishing : string
30
-
(** ["$phishing"] *)
36
+
(** ["$phishing"] - The Email is highly likely to be phishing. *)
31
37
32
38
val junk : string
33
-
(** ["$junk"] *)
39
+
(** ["$junk"] - The Email is definitely spam. *)
34
40
35
41
val not_junk : string
36
-
(** ["$notjunk"] *)
42
+
(** ["$notjunk"] - The Email is definitely not spam. *)
43
+
44
+
(** {2 draft-ietf-mailmaint Extended Keywords} *)
45
+
46
+
val notify : string
47
+
(** ["$notify"] - A notification should be shown for this message. *)
48
+
49
+
val muted : string
50
+
(** ["$muted"] - The user is not interested in future replies to this thread. *)
51
+
52
+
val followed : string
53
+
(** ["$followed"] - The user is particularly interested in future replies
54
+
to this thread. Mutually exclusive with muted. *)
55
+
56
+
val memo : string
57
+
(** ["$memo"] - The message is a note-to-self regarding another message
58
+
in the same thread. *)
59
+
60
+
val has_memo : string
61
+
(** ["$hasmemo"] - The message has an associated memo with the $memo keyword. *)
62
+
63
+
val has_attachment : string
64
+
(** ["$hasattachment"] - The message has an attachment (server-set). *)
65
+
66
+
val has_no_attachment : string
67
+
(** ["$hasnoattachment"] - The message does not have an attachment (server-set). *)
68
+
69
+
val auto_sent : string
70
+
(** ["$autosent"] - The message was sent automatically as a response
71
+
due to a user rule or setting (e.g., vacation response). *)
72
+
73
+
val unsubscribed : string
74
+
(** ["$unsubscribed"] - The client has unsubscribed from this mailing list. *)
75
+
76
+
val can_unsubscribe : string
77
+
(** ["$canunsubscribe"] - The message has an RFC8058-compliant
78
+
List-Unsubscribe header. *)
79
+
80
+
val imported : string
81
+
(** ["$imported"] - The message was imported from another mailbox. *)
82
+
83
+
val is_trusted : string
84
+
(** ["$istrusted"] - The authenticity of the from name and email address
85
+
have been verified with complete confidence by the server. *)
86
+
87
+
val masked_email : string
88
+
(** ["$maskedemail"] - The message was received via an alias created for
89
+
an individual sender to hide the user's real email address. *)
90
+
91
+
val new_ : string
92
+
(** ["$new"] - The message should be made more prominent to the user
93
+
due to a recent action (e.g., awakening from snooze). *)
94
+
95
+
(** {2 Apple Mail Flag Color Keywords}
96
+
97
+
These 3 keywords form a 3-bit bitmask defining the flag color:
98
+
- 000 = red, 100 = orange, 010 = yellow, 111 = green
99
+
- 001 = blue, 101 = purple, 011 = gray
100
+
101
+
These are only meaningful when the message has the $flagged keyword set. *)
102
+
103
+
val mail_flag_bit0 : string
104
+
(** ["$MailFlagBit0"] - Bit 0 of the flag color bitmask. *)
105
+
106
+
val mail_flag_bit1 : string
107
+
(** ["$MailFlagBit1"] - Bit 1 of the flag color bitmask. *)
108
+
109
+
val mail_flag_bit2 : string
110
+
(** ["$MailFlagBit2"] - Bit 2 of the flag color bitmask. *)
111
+
112
+
(** {2 Flag Color Type}
113
+
114
+
High-level type for working with Apple Mail flag colors. *)
115
+
116
+
type flag_color = [
117
+
| `Red (** Bits: 000 *)
118
+
| `Orange (** Bits: 100 *)
119
+
| `Yellow (** Bits: 010 *)
120
+
| `Green (** Bits: 111 *)
121
+
| `Blue (** Bits: 001 *)
122
+
| `Purple (** Bits: 101 *)
123
+
| `Gray (** Bits: 011 *)
124
+
]
125
+
126
+
val flag_color_to_keywords : flag_color -> string list
127
+
(** [flag_color_to_keywords color] returns the list of $MailFlagBit keywords
128
+
that should be set for the given color. *)
129
+
130
+
val flag_color_of_keywords : string list -> flag_color option
131
+
(** [flag_color_of_keywords keywords] extracts the flag color from a list
132
+
of keywords, if the $MailFlagBit keywords are present. Returns [None]
133
+
if no color bits are set (defaults to red when $flagged is set). *)
37
134
end
38
135
136
+
(** {1 Email Properties}
137
+
138
+
Polymorphic variants for type-safe property selection in Email/get requests.
139
+
These correspond to the properties defined in RFC 8621 Section 4.1. *)
140
+
141
+
(** Metadata properties (RFC 8621 Section 4.1.1).
142
+
These represent data about the message in the mail store. *)
143
+
type metadata_property = [
144
+
| `Id
145
+
| `Blob_id
146
+
| `Thread_id
147
+
| `Mailbox_ids
148
+
| `Keywords
149
+
| `Size
150
+
| `Received_at
151
+
]
152
+
153
+
(** Convenience header properties (RFC 8621 Section 4.1.3).
154
+
These are shortcuts for specific header:*:form properties. *)
155
+
type header_convenience_property = [
156
+
| `Message_id (** = header:Message-ID:asMessageIds *)
157
+
| `In_reply_to (** = header:In-Reply-To:asMessageIds *)
158
+
| `References (** = header:References:asMessageIds *)
159
+
| `Sender (** = header:Sender:asAddresses *)
160
+
| `From (** = header:From:asAddresses *)
161
+
| `To (** = header:To:asAddresses *)
162
+
| `Cc (** = header:Cc:asAddresses *)
163
+
| `Bcc (** = header:Bcc:asAddresses *)
164
+
| `Reply_to (** = header:Reply-To:asAddresses *)
165
+
| `Subject (** = header:Subject:asText *)
166
+
| `Sent_at (** = header:Date:asDate *)
167
+
| `Headers (** All headers in raw form *)
168
+
]
169
+
170
+
(** Body properties (RFC 8621 Section 4.1.4).
171
+
These represent the message body structure and content. *)
172
+
type body_property = [
173
+
| `Body_structure
174
+
| `Body_values
175
+
| `Text_body
176
+
| `Html_body
177
+
| `Attachments
178
+
| `Has_attachment
179
+
| `Preview
180
+
]
181
+
182
+
(** All standard Email properties. *)
183
+
type standard_property = [
184
+
| metadata_property
185
+
| header_convenience_property
186
+
| body_property
187
+
]
188
+
189
+
(** A dynamic header property request.
190
+
Use {!Mail_header.header_property} for type-safe construction. *)
191
+
type header_property = [ `Header of Mail_header.header_property ]
192
+
193
+
(** Any Email property - standard or dynamic header. *)
194
+
type property = [ standard_property | header_property ]
195
+
196
+
val property_to_string : [< property ] -> string
197
+
(** Convert a property to its wire name (e.g., [`From] -> "from"). *)
198
+
199
+
val property_of_string : string -> property option
200
+
(** Parse a property name. Returns [None] for unrecognized properties.
201
+
Handles both standard properties and header:* properties. *)
202
+
203
+
val standard_property_of_string : string -> standard_property option
204
+
(** Parse only standard property names (not header:* properties). *)
205
+
206
+
(** {1 Body Part Properties}
207
+
208
+
Properties that can be requested for EmailBodyPart objects
209
+
via the [bodyProperties] argument. *)
210
+
211
+
type body_part_property = [
212
+
| `Part_id
213
+
| `Blob_id
214
+
| `Size
215
+
| `Part_headers (** Named [headers] in the wire format *)
216
+
| `Name
217
+
| `Type
218
+
| `Charset
219
+
| `Disposition
220
+
| `Cid
221
+
| `Language
222
+
| `Location
223
+
| `Sub_parts
224
+
]
225
+
226
+
val body_part_property_to_string : [< body_part_property ] -> string
227
+
(** Convert a body part property to its wire name. *)
228
+
229
+
val body_part_property_of_string : string -> body_part_property option
230
+
(** Parse a body part property name. *)
231
+
39
232
(** {1 Email Object} *)
40
233
41
234
type t = {
42
235
(* Metadata - server-set, immutable *)
43
-
id : Proto_id.t;
44
-
blob_id : Proto_id.t;
45
-
thread_id : Proto_id.t;
46
-
size : int64;
47
-
received_at : Ptime.t;
236
+
id : Proto_id.t option;
237
+
blob_id : Proto_id.t option;
238
+
thread_id : Proto_id.t option;
239
+
size : int64 option;
240
+
received_at : Ptime.t option;
48
241
49
242
(* Metadata - mutable *)
50
-
mailbox_ids : (Proto_id.t * bool) list;
51
-
keywords : (string * bool) list;
243
+
mailbox_ids : (Proto_id.t * bool) list option;
244
+
keywords : (string * bool) list option;
52
245
53
246
(* Parsed headers *)
54
247
message_id : string list option;
···
72
265
text_body : Mail_body.Part.t list option;
73
266
html_body : Mail_body.Part.t list option;
74
267
attachments : Mail_body.Part.t list option;
75
-
has_attachment : bool;
76
-
preview : string;
268
+
has_attachment : bool option;
269
+
preview : string option;
270
+
271
+
(* Dynamic header properties - stored as raw JSON for lazy decoding *)
272
+
dynamic_headers : (string * Jsont.json) list;
273
+
(** Raw header values from [header:*] property requests.
274
+
The key is the full property name (e.g., "header:X-Custom:asText").
275
+
Use {!decode_header_value} to parse into typed values. *)
77
276
}
78
277
79
-
val id : t -> Proto_id.t
80
-
val blob_id : t -> Proto_id.t
81
-
val thread_id : t -> Proto_id.t
82
-
val size : t -> int64
83
-
val received_at : t -> Ptime.t
84
-
val mailbox_ids : t -> (Proto_id.t * bool) list
85
-
val keywords : t -> (string * bool) list
278
+
(** {2 Accessors}
279
+
280
+
All accessors return [option] types since the response only includes
281
+
properties that were requested. *)
282
+
283
+
val id : t -> Proto_id.t option
284
+
val blob_id : t -> Proto_id.t option
285
+
val thread_id : t -> Proto_id.t option
286
+
val size : t -> int64 option
287
+
val received_at : t -> Ptime.t option
288
+
val mailbox_ids : t -> (Proto_id.t * bool) list option
289
+
val keywords : t -> (string * bool) list option
86
290
val message_id : t -> string list option
87
291
val in_reply_to : t -> string list option
88
292
val references : t -> string list option
···
100
304
val text_body : t -> Mail_body.Part.t list option
101
305
val html_body : t -> Mail_body.Part.t list option
102
306
val attachments : t -> Mail_body.Part.t list option
103
-
val has_attachment : t -> bool
104
-
val preview : t -> string
307
+
val has_attachment : t -> bool option
308
+
val preview : t -> string option
309
+
val dynamic_headers_raw : t -> (string * Jsont.json) list
310
+
(** Get raw dynamic headers. Use {!decode_header_value} to parse them. *)
311
+
312
+
(** {2 Dynamic Header Decoding} *)
313
+
314
+
val decode_header_value : string -> Jsont.json -> Mail_header.header_value option
315
+
(** [decode_header_value prop_name json] decodes a raw JSON value into a typed
316
+
header value based on the property name. The property name determines the form:
317
+
- [header:Name] or [header:Name:all] -> Raw/Text (String_single/String_all)
318
+
- [header:Name:asText] -> Text (String_single)
319
+
- [header:Name:asAddresses] -> Addresses (Addresses_single)
320
+
- [header:Name:asGroupedAddresses] -> Grouped (Grouped_single)
321
+
- [header:Name:asMessageIds] -> MessageIds (Strings_single)
322
+
- [header:Name:asDate] -> Date (Date_single)
323
+
- [header:Name:asURLs] -> URLs (Strings_single)
324
+
Returns [None] if the property name is invalid or decoding fails. *)
325
+
326
+
val get_header : t -> string -> Mail_header.header_value option
327
+
(** [get_header email key] looks up and decodes a dynamic header by its full
328
+
property name. E.g., [get_header email "header:X-Custom:asText"]. *)
329
+
330
+
val get_header_string : t -> string -> string option
331
+
(** [get_header_string email key] looks up a string header value.
332
+
Returns [None] if not found or if the value is not a string type. *)
333
+
334
+
val get_header_addresses : t -> string -> Mail_address.t list option
335
+
(** [get_header_addresses email key] looks up an addresses header value.
336
+
Returns [None] if not found or if the value is not an addresses type. *)
105
337
106
338
val jsont : t Jsont.t
339
+
(** Permissive JSON codec that handles any subset of properties.
340
+
Unknown [header:*] properties are decoded into {!dynamic_headers}. *)
107
341
108
342
(** {1 Email Filter Conditions} *)
109
343
···
136
370
137
371
(** {1 Email/get Arguments} *)
138
372
139
-
(** Extra arguments for Email/get beyond standard /get. *)
373
+
(** Extra arguments for Email/get beyond standard /get.
374
+
375
+
Note: The standard [properties] argument from {!Proto_method.get_args}
376
+
should use {!property} variants converted via {!property_to_string}. *)
140
377
type get_args_extra = {
141
-
body_properties : string list option;
378
+
body_properties : body_part_property list option;
379
+
(** Properties to fetch for each EmailBodyPart.
380
+
If omitted, defaults to all properties. *)
142
381
fetch_text_body_values : bool;
382
+
(** If [true], fetch body values for text/* parts in textBody. *)
143
383
fetch_html_body_values : bool;
384
+
(** If [true], fetch body values for text/* parts in htmlBody. *)
144
385
fetch_all_body_values : bool;
386
+
(** If [true], fetch body values for all text/* parts. *)
145
387
max_body_value_bytes : int64 option;
388
+
(** Maximum size of body values to return. Larger values are truncated. *)
146
389
}
390
+
391
+
val get_args_extra :
392
+
?body_properties:body_part_property list ->
393
+
?fetch_text_body_values:bool ->
394
+
?fetch_html_body_values:bool ->
395
+
?fetch_all_body_values:bool ->
396
+
?max_body_value_bytes:int64 ->
397
+
unit ->
398
+
get_args_extra
399
+
(** Convenience constructor with sensible defaults. *)
147
400
148
401
val get_args_extra_jsont : get_args_extra Jsont.t
+332
-1
lib/mail/mail_header.ml
+332
-1
lib/mail/mail_header.ml
···
22
22
|> Jsont.Object.mem "value" Jsont.string ~enc:value
23
23
|> Jsont.Object.finish
24
24
25
-
(* Header parsed forms - these are used with header:Name:form properties *)
25
+
(* Header categories *)
26
+
27
+
type address_header = [
28
+
| `From
29
+
| `Sender
30
+
| `Reply_to
31
+
| `To
32
+
| `Cc
33
+
| `Bcc
34
+
| `Resent_from
35
+
| `Resent_sender
36
+
| `Resent_reply_to
37
+
| `Resent_to
38
+
| `Resent_cc
39
+
| `Resent_bcc
40
+
]
41
+
42
+
type message_id_header = [
43
+
| `Message_id
44
+
| `In_reply_to
45
+
| `References
46
+
| `Resent_message_id
47
+
]
48
+
49
+
type date_header = [
50
+
| `Date
51
+
| `Resent_date
52
+
]
53
+
54
+
type url_header = [
55
+
| `List_help
56
+
| `List_unsubscribe
57
+
| `List_subscribe
58
+
| `List_post
59
+
| `List_owner
60
+
| `List_archive
61
+
]
62
+
63
+
type text_header = [
64
+
| `Subject
65
+
| `Comments
66
+
| `Keywords
67
+
| `List_id
68
+
]
69
+
70
+
type standard_header = [
71
+
| address_header
72
+
| message_id_header
73
+
| date_header
74
+
| url_header
75
+
| text_header
76
+
]
77
+
78
+
type custom_header = [ `Custom of string ]
79
+
80
+
type any_header = [ standard_header | custom_header ]
81
+
82
+
let standard_header_to_string : [< standard_header ] -> string = function
83
+
| `From -> "From"
84
+
| `Sender -> "Sender"
85
+
| `Reply_to -> "Reply-To"
86
+
| `To -> "To"
87
+
| `Cc -> "Cc"
88
+
| `Bcc -> "Bcc"
89
+
| `Resent_from -> "Resent-From"
90
+
| `Resent_sender -> "Resent-Sender"
91
+
| `Resent_reply_to -> "Resent-Reply-To"
92
+
| `Resent_to -> "Resent-To"
93
+
| `Resent_cc -> "Resent-Cc"
94
+
| `Resent_bcc -> "Resent-Bcc"
95
+
| `Message_id -> "Message-ID"
96
+
| `In_reply_to -> "In-Reply-To"
97
+
| `References -> "References"
98
+
| `Resent_message_id -> "Resent-Message-ID"
99
+
| `Date -> "Date"
100
+
| `Resent_date -> "Resent-Date"
101
+
| `List_help -> "List-Help"
102
+
| `List_unsubscribe -> "List-Unsubscribe"
103
+
| `List_subscribe -> "List-Subscribe"
104
+
| `List_post -> "List-Post"
105
+
| `List_owner -> "List-Owner"
106
+
| `List_archive -> "List-Archive"
107
+
| `Subject -> "Subject"
108
+
| `Comments -> "Comments"
109
+
| `Keywords -> "Keywords"
110
+
| `List_id -> "List-Id"
111
+
112
+
let standard_header_of_string s : standard_header option =
113
+
match String.lowercase_ascii s with
114
+
| "from" -> Some `From
115
+
| "sender" -> Some `Sender
116
+
| "reply-to" -> Some `Reply_to
117
+
| "to" -> Some `To
118
+
| "cc" -> Some `Cc
119
+
| "bcc" -> Some `Bcc
120
+
| "resent-from" -> Some `Resent_from
121
+
| "resent-sender" -> Some `Resent_sender
122
+
| "resent-reply-to" -> Some `Resent_reply_to
123
+
| "resent-to" -> Some `Resent_to
124
+
| "resent-cc" -> Some `Resent_cc
125
+
| "resent-bcc" -> Some `Resent_bcc
126
+
| "message-id" -> Some `Message_id
127
+
| "in-reply-to" -> Some `In_reply_to
128
+
| "references" -> Some `References
129
+
| "resent-message-id" -> Some `Resent_message_id
130
+
| "date" -> Some `Date
131
+
| "resent-date" -> Some `Resent_date
132
+
| "list-help" -> Some `List_help
133
+
| "list-unsubscribe" -> Some `List_unsubscribe
134
+
| "list-subscribe" -> Some `List_subscribe
135
+
| "list-post" -> Some `List_post
136
+
| "list-owner" -> Some `List_owner
137
+
| "list-archive" -> Some `List_archive
138
+
| "subject" -> Some `Subject
139
+
| "comments" -> Some `Comments
140
+
| "keywords" -> Some `Keywords
141
+
| "list-id" -> Some `List_id
142
+
| _ -> None
143
+
144
+
let any_header_to_string : [< any_header ] -> string = function
145
+
| `Custom s -> s
146
+
| #standard_header as h -> standard_header_to_string h
147
+
148
+
(* Header parsed forms *)
149
+
150
+
type form = [
151
+
| `Raw
152
+
| `Text
153
+
| `Addresses
154
+
| `Grouped_addresses
155
+
| `Message_ids
156
+
| `Date
157
+
| `Urls
158
+
]
159
+
160
+
let form_to_string : [< form ] -> string = function
161
+
| `Raw -> ""
162
+
| `Text -> "asText"
163
+
| `Addresses -> "asAddresses"
164
+
| `Grouped_addresses -> "asGroupedAddresses"
165
+
| `Message_ids -> "asMessageIds"
166
+
| `Date -> "asDate"
167
+
| `Urls -> "asURLs"
168
+
169
+
let form_of_string s : form option =
170
+
match s with
171
+
| "" -> Some `Raw
172
+
| "asText" -> Some `Text
173
+
| "asAddresses" -> Some `Addresses
174
+
| "asGroupedAddresses" -> Some `Grouped_addresses
175
+
| "asMessageIds" -> Some `Message_ids
176
+
| "asDate" -> Some `Date
177
+
| "asURLs" -> Some `Urls
178
+
| _ -> None
179
+
180
+
(* Header property requests *)
181
+
182
+
type header_property =
183
+
| Raw of { name : string; all : bool }
184
+
| Text of { header : [ text_header | custom_header ]; all : bool }
185
+
| Addresses of { header : [ address_header | custom_header ]; all : bool }
186
+
| Grouped_addresses of { header : [ address_header | custom_header ]; all : bool }
187
+
| Message_ids of { header : [ message_id_header | custom_header ]; all : bool }
188
+
| Date of { header : [ date_header | custom_header ]; all : bool }
189
+
| Urls of { header : [ url_header | custom_header ]; all : bool }
190
+
191
+
let header_name_of_property : header_property -> string = function
192
+
| Raw { name; _ } -> name
193
+
| Text { header; _ } -> any_header_to_string (header :> any_header)
194
+
| Addresses { header; _ } -> any_header_to_string (header :> any_header)
195
+
| Grouped_addresses { header; _ } -> any_header_to_string (header :> any_header)
196
+
| Message_ids { header; _ } -> any_header_to_string (header :> any_header)
197
+
| Date { header; _ } -> any_header_to_string (header :> any_header)
198
+
| Urls { header; _ } -> any_header_to_string (header :> any_header)
199
+
200
+
let header_property_all : header_property -> bool = function
201
+
| Raw { all; _ } -> all
202
+
| Text { all; _ } -> all
203
+
| Addresses { all; _ } -> all
204
+
| Grouped_addresses { all; _ } -> all
205
+
| Message_ids { all; _ } -> all
206
+
| Date { all; _ } -> all
207
+
| Urls { all; _ } -> all
208
+
209
+
let header_property_form : header_property -> form = function
210
+
| Raw _ -> `Raw
211
+
| Text _ -> `Text
212
+
| Addresses _ -> `Addresses
213
+
| Grouped_addresses _ -> `Grouped_addresses
214
+
| Message_ids _ -> `Message_ids
215
+
| Date _ -> `Date
216
+
| Urls _ -> `Urls
217
+
218
+
let header_property_to_string prop =
219
+
let name = header_name_of_property prop in
220
+
let form = form_to_string (header_property_form prop) in
221
+
let all_suffix = if header_property_all prop then ":all" else "" in
222
+
let form_suffix = if form = "" then "" else ":" ^ form in
223
+
"header:" ^ name ^ form_suffix ^ all_suffix
224
+
225
+
let header_property_of_string s : header_property option =
226
+
if not (String.length s > 7 && String.sub s 0 7 = "header:") then
227
+
None
228
+
else
229
+
let rest = String.sub s 7 (String.length s - 7) in
230
+
(* Parse the parts: name[:form][:all] *)
231
+
let parts = String.split_on_char ':' rest in
232
+
match parts with
233
+
| [] -> None
234
+
| [name] ->
235
+
Some (Raw { name; all = false })
236
+
| [name; second] ->
237
+
if second = "all" then
238
+
Some (Raw { name; all = true })
239
+
else begin
240
+
match form_of_string second with
241
+
| None -> None
242
+
| Some `Raw -> Some (Raw { name; all = false })
243
+
| Some `Text -> Some (Text { header = `Custom name; all = false })
244
+
| Some `Addresses -> Some (Addresses { header = `Custom name; all = false })
245
+
| Some `Grouped_addresses -> Some (Grouped_addresses { header = `Custom name; all = false })
246
+
| Some `Message_ids -> Some (Message_ids { header = `Custom name; all = false })
247
+
| Some `Date -> Some (Date { header = `Custom name; all = false })
248
+
| Some `Urls -> Some (Urls { header = `Custom name; all = false })
249
+
end
250
+
| [name; form_str; "all"] ->
251
+
begin match form_of_string form_str with
252
+
| None -> None
253
+
| Some `Raw -> Some (Raw { name; all = true })
254
+
| Some `Text -> Some (Text { header = `Custom name; all = true })
255
+
| Some `Addresses -> Some (Addresses { header = `Custom name; all = true })
256
+
| Some `Grouped_addresses -> Some (Grouped_addresses { header = `Custom name; all = true })
257
+
| Some `Message_ids -> Some (Message_ids { header = `Custom name; all = true })
258
+
| Some `Date -> Some (Date { header = `Custom name; all = true })
259
+
| Some `Urls -> Some (Urls { header = `Custom name; all = true })
260
+
end
261
+
| _ -> None
262
+
263
+
(* Convenience constructors *)
264
+
265
+
let raw ?(all=false) name = Raw { name; all }
266
+
267
+
let text ?(all=false) header = Text { header; all }
268
+
269
+
let addresses ?(all=false) header = Addresses { header; all }
270
+
271
+
let grouped_addresses ?(all=false) header = Grouped_addresses { header; all }
272
+
273
+
let message_ids ?(all=false) header = Message_ids { header; all }
274
+
275
+
let date ?(all=false) header = Date { header; all }
276
+
277
+
let urls ?(all=false) header = Urls { header; all }
278
+
279
+
(* Header values in responses *)
280
+
281
+
type header_value =
282
+
| String_single of string option
283
+
| String_all of string list
284
+
| Addresses_single of Mail_address.t list option
285
+
| Addresses_all of Mail_address.t list list
286
+
| Grouped_single of Mail_address.Group.t list option
287
+
| Grouped_all of Mail_address.Group.t list list
288
+
| Date_single of Ptime.t option
289
+
| Date_all of Ptime.t option list
290
+
| Strings_single of string list option
291
+
| Strings_all of string list option list
292
+
293
+
let header_value_jsont ~form ~all : header_value Jsont.t =
294
+
match form, all with
295
+
| (`Raw | `Text), false ->
296
+
Jsont.map
297
+
~dec:(fun s -> String_single s)
298
+
~enc:(function String_single s -> s | _ -> None)
299
+
(Jsont.option Jsont.string)
300
+
| (`Raw | `Text), true ->
301
+
Jsont.map
302
+
~dec:(fun l -> String_all l)
303
+
~enc:(function String_all l -> l | _ -> [])
304
+
(Jsont.list Jsont.string)
305
+
| `Addresses, false ->
306
+
Jsont.map
307
+
~dec:(fun l -> Addresses_single l)
308
+
~enc:(function Addresses_single l -> l | _ -> None)
309
+
(Jsont.option (Jsont.list Mail_address.jsont))
310
+
| `Addresses, true ->
311
+
Jsont.map
312
+
~dec:(fun l -> Addresses_all l)
313
+
~enc:(function Addresses_all l -> l | _ -> [])
314
+
(Jsont.list (Jsont.list Mail_address.jsont))
315
+
| `Grouped_addresses, false ->
316
+
Jsont.map
317
+
~dec:(fun l -> Grouped_single l)
318
+
~enc:(function Grouped_single l -> l | _ -> None)
319
+
(Jsont.option (Jsont.list Mail_address.Group.jsont))
320
+
| `Grouped_addresses, true ->
321
+
Jsont.map
322
+
~dec:(fun l -> Grouped_all l)
323
+
~enc:(function Grouped_all l -> l | _ -> [])
324
+
(Jsont.list (Jsont.list Mail_address.Group.jsont))
325
+
| `Message_ids, false ->
326
+
Jsont.map
327
+
~dec:(fun l -> Strings_single l)
328
+
~enc:(function Strings_single l -> l | _ -> None)
329
+
(Jsont.option (Jsont.list Jsont.string))
330
+
| `Message_ids, true ->
331
+
Jsont.map
332
+
~dec:(fun l -> Strings_all l)
333
+
~enc:(function Strings_all l -> l | _ -> [])
334
+
(Jsont.list (Jsont.option (Jsont.list Jsont.string)))
335
+
| `Date, false ->
336
+
Jsont.map
337
+
~dec:(fun t -> Date_single t)
338
+
~enc:(function Date_single t -> t | _ -> None)
339
+
(Jsont.option Proto_date.Rfc3339.jsont)
340
+
| `Date, true ->
341
+
Jsont.map
342
+
~dec:(fun l -> Date_all l)
343
+
~enc:(function Date_all l -> l | _ -> [])
344
+
(Jsont.list (Jsont.option Proto_date.Rfc3339.jsont))
345
+
| `Urls, false ->
346
+
Jsont.map
347
+
~dec:(fun l -> Strings_single l)
348
+
~enc:(function Strings_single l -> l | _ -> None)
349
+
(Jsont.option (Jsont.list Jsont.string))
350
+
| `Urls, true ->
351
+
Jsont.map
352
+
~dec:(fun l -> Strings_all l)
353
+
~enc:(function Strings_all l -> l | _ -> [])
354
+
(Jsont.list (Jsont.option (Jsont.list Jsont.string)))
355
+
356
+
(* Low-level JSON codecs *)
26
357
27
358
let raw_jsont = Jsont.string
28
359
+234
-2
lib/mail/mail_header.mli
+234
-2
lib/mail/mail_header.mli
···
3
3
SPDX-License-Identifier: ISC
4
4
---------------------------------------------------------------------------*)
5
5
6
-
(** Email header types as defined in RFC 8621 Section 4.1.2
6
+
(** Email header types as defined in RFC 8621 Section 4.1.2
7
7
8
8
@canonical Jmap.Proto.Email_header *)
9
9
···
24
24
25
25
val jsont : t Jsont.t
26
26
27
+
(** {1 Header Categories}
28
+
29
+
RFC 8621 Section 4.1.2 restricts which parsed forms can be used with
30
+
which headers. These polymorphic variant types encode those restrictions
31
+
at the type level.
32
+
33
+
Each category corresponds to headers that share the same allowed forms:
34
+
- Address headers: can use [Addresses] and [Grouped_addresses] forms
35
+
- Message-ID headers: can use [Message_ids] form
36
+
- Date headers: can use [Date] form
37
+
- URL headers: can use [Urls] form
38
+
- Text headers: can use [Text] form
39
+
- All headers can use [Raw] form
40
+
- Custom headers (not in RFC 5322/2369) can use any form *)
41
+
42
+
(** Headers that allow the [Addresses] and [Grouped_addresses] forms.
43
+
These are address-list headers per RFC 5322. *)
44
+
type address_header = [
45
+
| `From
46
+
| `Sender
47
+
| `Reply_to
48
+
| `To
49
+
| `Cc
50
+
| `Bcc
51
+
| `Resent_from
52
+
| `Resent_sender
53
+
| `Resent_reply_to
54
+
| `Resent_to
55
+
| `Resent_cc
56
+
| `Resent_bcc
57
+
]
58
+
59
+
(** Headers that allow the [Message_ids] form.
60
+
These contain msg-id values per RFC 5322. *)
61
+
type message_id_header = [
62
+
| `Message_id
63
+
| `In_reply_to
64
+
| `References
65
+
| `Resent_message_id
66
+
]
67
+
68
+
(** Headers that allow the [Date] form.
69
+
These contain date-time values per RFC 5322. *)
70
+
type date_header = [
71
+
| `Date
72
+
| `Resent_date
73
+
]
74
+
75
+
(** Headers that allow the [Urls] form.
76
+
These are list-* headers per RFC 2369. *)
77
+
type url_header = [
78
+
| `List_help
79
+
| `List_unsubscribe
80
+
| `List_subscribe
81
+
| `List_post
82
+
| `List_owner
83
+
| `List_archive
84
+
]
85
+
86
+
(** Headers that allow the [Text] form.
87
+
These contain unstructured or phrase content. *)
88
+
type text_header = [
89
+
| `Subject
90
+
| `Comments
91
+
| `Keywords
92
+
| `List_id
93
+
]
94
+
95
+
(** All standard headers defined in RFC 5322 and RFC 2369. *)
96
+
type standard_header = [
97
+
| address_header
98
+
| message_id_header
99
+
| date_header
100
+
| url_header
101
+
| text_header
102
+
]
103
+
104
+
(** A custom header not defined in RFC 5322 or RFC 2369.
105
+
Custom headers can use any parsed form. *)
106
+
type custom_header = [ `Custom of string ]
107
+
108
+
(** Any header - standard or custom. *)
109
+
type any_header = [ standard_header | custom_header ]
110
+
111
+
(** {2 Header Name Conversion} *)
112
+
113
+
val standard_header_to_string : [< standard_header ] -> string
114
+
(** Convert a standard header variant to its wire name (e.g., [`From] -> "From"). *)
115
+
116
+
val standard_header_of_string : string -> standard_header option
117
+
(** Parse a header name to a standard header variant, case-insensitive.
118
+
Returns [None] for non-standard headers. *)
119
+
120
+
val any_header_to_string : [< any_header ] -> string
121
+
(** Convert any header variant to its wire name. *)
122
+
27
123
(** {1 Header Parsed Forms}
28
124
29
125
RFC 8621 defines several parsed forms for headers.
30
-
These can be requested via the header:Name:form properties. *)
126
+
These can be requested via the [header:Name:form] properties. *)
127
+
128
+
(** The parsed form to request for a header value. *)
129
+
type form = [
130
+
| `Raw (** Raw octets, available for all headers *)
131
+
| `Text (** Decoded text, for text headers or custom *)
132
+
| `Addresses (** Flat address list, for address headers or custom *)
133
+
| `Grouped_addresses (** Address list with groups, for address headers or custom *)
134
+
| `Message_ids (** List of message-id strings, for message-id headers or custom *)
135
+
| `Date (** Parsed date, for date headers or custom *)
136
+
| `Urls (** List of URLs, for url headers or custom *)
137
+
]
138
+
139
+
val form_to_string : [< form ] -> string
140
+
(** Convert form to wire suffix (e.g., [`Addresses] -> "asAddresses").
141
+
[`Raw] returns the empty string (raw is the default). *)
142
+
143
+
val form_of_string : string -> form option
144
+
(** Parse a form suffix (e.g., "asAddresses" -> [`Addresses]).
145
+
Empty string returns [`Raw]. *)
146
+
147
+
(** {1 Header Property Requests}
148
+
149
+
Type-safe construction of [header:Name:form:all] property strings.
150
+
The GADT ensures that only valid form/header combinations are allowed. *)
151
+
152
+
(** A header property request with type-safe form selection.
153
+
154
+
The type parameter encodes what forms are allowed:
155
+
- Address headers allow [Addresses] and [Grouped_addresses]
156
+
- Message-ID headers allow [Message_ids]
157
+
- Date headers allow [Date]
158
+
- URL headers allow [Urls]
159
+
- Text headers allow [Text]
160
+
- All headers allow [Raw]
161
+
- Custom headers allow any form *)
162
+
type header_property =
163
+
| Raw of { name : string; all : bool }
164
+
(** Raw form, available for any header. *)
165
+
166
+
| Text of { header : [ text_header | custom_header ]; all : bool }
167
+
(** Text form, for text headers or custom. *)
168
+
169
+
| Addresses of { header : [ address_header | custom_header ]; all : bool }
170
+
(** Addresses form, for address headers or custom. *)
171
+
172
+
| Grouped_addresses of { header : [ address_header | custom_header ]; all : bool }
173
+
(** GroupedAddresses form, for address headers or custom. *)
174
+
175
+
| Message_ids of { header : [ message_id_header | custom_header ]; all : bool }
176
+
(** MessageIds form, for message-id headers or custom. *)
177
+
178
+
| Date of { header : [ date_header | custom_header ]; all : bool }
179
+
(** Date form, for date headers or custom. *)
180
+
181
+
| Urls of { header : [ url_header | custom_header ]; all : bool }
182
+
(** URLs form, for URL headers or custom. *)
183
+
184
+
val header_property_to_string : header_property -> string
185
+
(** Convert a header property request to wire format.
186
+
E.g., [Addresses { header = `From; all = true }] -> "header:From:asAddresses:all" *)
187
+
188
+
val header_property_of_string : string -> header_property option
189
+
(** Parse a header property string.
190
+
Returns [None] if the string doesn't match [header:*] format. *)
191
+
192
+
(** {2 Convenience Constructors} *)
193
+
194
+
val raw : ?all:bool -> string -> header_property
195
+
(** [raw ?all name] creates a raw header property request. *)
196
+
197
+
val text : ?all:bool -> [ text_header | custom_header ] -> header_property
198
+
(** [text ?all header] creates a text header property request. *)
199
+
200
+
val addresses : ?all:bool -> [ address_header | custom_header ] -> header_property
201
+
(** [addresses ?all header] creates an addresses header property request. *)
202
+
203
+
val grouped_addresses : ?all:bool -> [ address_header | custom_header ] -> header_property
204
+
(** [grouped_addresses ?all header] creates a grouped addresses header property request. *)
205
+
206
+
val message_ids : ?all:bool -> [ message_id_header | custom_header ] -> header_property
207
+
(** [message_ids ?all header] creates a message-ids header property request. *)
208
+
209
+
val date : ?all:bool -> [ date_header | custom_header ] -> header_property
210
+
(** [date ?all header] creates a date header property request. *)
211
+
212
+
val urls : ?all:bool -> [ url_header | custom_header ] -> header_property
213
+
(** [urls ?all header] creates a URLs header property request. *)
214
+
215
+
(** {1 Header Values in Responses}
216
+
217
+
When fetching dynamic headers, the response value type depends on the
218
+
requested form. This type captures all possible response shapes. *)
219
+
220
+
(** A header value from the response.
221
+
222
+
The variant encodes both the form and whether [:all] was requested:
223
+
- [*_single] variants: value of the last header instance, or [None] if absent
224
+
- [*_all] variants: list of values for all instances, empty if absent *)
225
+
type header_value =
226
+
| String_single of string option
227
+
(** Raw or Text form, single instance. *)
228
+
229
+
| String_all of string list
230
+
(** Raw or Text form, all instances. *)
231
+
232
+
| Addresses_single of Mail_address.t list option
233
+
(** Addresses form, single instance. *)
234
+
235
+
| Addresses_all of Mail_address.t list list
236
+
(** Addresses form, all instances. *)
237
+
238
+
| Grouped_single of Mail_address.Group.t list option
239
+
(** GroupedAddresses form, single instance. *)
240
+
241
+
| Grouped_all of Mail_address.Group.t list list
242
+
(** GroupedAddresses form, all instances. *)
243
+
244
+
| Date_single of Ptime.t option
245
+
(** Date form, single instance. *)
246
+
247
+
| Date_all of Ptime.t option list
248
+
(** Date form, all instances. *)
249
+
250
+
| Strings_single of string list option
251
+
(** MessageIds or URLs form, single instance. *)
252
+
253
+
| Strings_all of string list option list
254
+
(** MessageIds or URLs form, all instances. *)
255
+
256
+
val header_value_jsont : form:form -> all:bool -> header_value Jsont.t
257
+
(** [header_value_jsont ~form ~all] returns a JSON codec for header values
258
+
with the given form and multiplicity. *)
259
+
260
+
(** {1 Low-level JSON Codecs}
261
+
262
+
These codecs are used internally and for custom header processing. *)
31
263
32
264
(** The raw form - header value as-is. *)
33
265
val raw_jsont : string Jsont.t
+49
-12
lib/mail/mail_identity.ml
+49
-12
lib/mail/mail_identity.ml
···
3
3
SPDX-License-Identifier: ISC
4
4
---------------------------------------------------------------------------*)
5
5
6
+
(* Identity properties *)
7
+
8
+
type property = [
9
+
| `Id
10
+
| `Name
11
+
| `Email
12
+
| `Reply_to
13
+
| `Bcc
14
+
| `Text_signature
15
+
| `Html_signature
16
+
| `May_delete
17
+
]
18
+
19
+
let property_to_string : [< property ] -> string = function
20
+
| `Id -> "id"
21
+
| `Name -> "name"
22
+
| `Email -> "email"
23
+
| `Reply_to -> "replyTo"
24
+
| `Bcc -> "bcc"
25
+
| `Text_signature -> "textSignature"
26
+
| `Html_signature -> "htmlSignature"
27
+
| `May_delete -> "mayDelete"
28
+
29
+
let property_of_string s : property option =
30
+
match s with
31
+
| "id" -> Some `Id
32
+
| "name" -> Some `Name
33
+
| "email" -> Some `Email
34
+
| "replyTo" -> Some `Reply_to
35
+
| "bcc" -> Some `Bcc
36
+
| "textSignature" -> Some `Text_signature
37
+
| "htmlSignature" -> Some `Html_signature
38
+
| "mayDelete" -> Some `May_delete
39
+
| _ -> None
40
+
41
+
(* Identity type *)
42
+
6
43
type t = {
7
-
id : Proto_id.t;
8
-
name : string;
9
-
email : string;
44
+
id : Proto_id.t option;
45
+
name : string option;
46
+
email : string option;
10
47
reply_to : Mail_address.t list option;
11
48
bcc : Mail_address.t list option;
12
-
text_signature : string;
13
-
html_signature : string;
14
-
may_delete : bool;
49
+
text_signature : string option;
50
+
html_signature : string option;
51
+
may_delete : bool option;
15
52
}
16
53
17
54
let id t = t.id
···
29
66
let jsont =
30
67
let kind = "Identity" in
31
68
Jsont.Object.map ~kind make
32
-
|> Jsont.Object.mem "id" Proto_id.jsont ~enc:id
33
-
|> Jsont.Object.mem "name" Jsont.string ~dec_absent:"" ~enc:name
34
-
|> Jsont.Object.mem "email" Jsont.string ~enc:email
69
+
|> Jsont.Object.opt_mem "id" Proto_id.jsont ~enc:id
70
+
|> Jsont.Object.opt_mem "name" Jsont.string ~enc:name
71
+
|> Jsont.Object.opt_mem "email" Jsont.string ~enc:email
35
72
|> Jsont.Object.opt_mem "replyTo" (Jsont.list Mail_address.jsont) ~enc:reply_to
36
73
|> Jsont.Object.opt_mem "bcc" (Jsont.list Mail_address.jsont) ~enc:bcc
37
-
|> Jsont.Object.mem "textSignature" Jsont.string ~dec_absent:"" ~enc:text_signature
38
-
|> Jsont.Object.mem "htmlSignature" Jsont.string ~dec_absent:"" ~enc:html_signature
39
-
|> Jsont.Object.mem "mayDelete" Jsont.bool ~enc:may_delete
74
+
|> Jsont.Object.opt_mem "textSignature" Jsont.string ~enc:text_signature
75
+
|> Jsont.Object.opt_mem "htmlSignature" Jsont.string ~enc:html_signature
76
+
|> Jsont.Object.opt_mem "mayDelete" Jsont.bool ~enc:may_delete
40
77
|> Jsont.Object.finish
+38
-13
lib/mail/mail_identity.mli
+38
-13
lib/mail/mail_identity.mli
···
3
3
SPDX-License-Identifier: ISC
4
4
---------------------------------------------------------------------------*)
5
5
6
-
(** Identity type as defined in RFC 8621 Section 6
6
+
(** Identity type as defined in RFC 8621 Section 6
7
7
8
8
@canonical Jmap.Proto.Identity *)
9
9
10
+
(** {1 Identity Properties}
11
+
12
+
Polymorphic variants for type-safe property selection in Identity/get requests.
13
+
These correspond to the properties defined in RFC 8621 Section 6. *)
14
+
15
+
(** All Identity properties that can be requested. *)
16
+
type property = [
17
+
| `Id
18
+
| `Name
19
+
| `Email
20
+
| `Reply_to
21
+
| `Bcc
22
+
| `Text_signature
23
+
| `Html_signature
24
+
| `May_delete
25
+
]
26
+
27
+
val property_to_string : [< property ] -> string
28
+
(** Convert a property to its wire name (e.g., [`Text_signature] -> "textSignature"). *)
29
+
30
+
val property_of_string : string -> property option
31
+
(** Parse a property name, case-sensitive. *)
32
+
33
+
(** {1 Identity Object} *)
34
+
10
35
type t = {
11
-
id : Proto_id.t;
36
+
id : Proto_id.t option;
12
37
(** Server-assigned identity id. *)
13
-
name : string;
38
+
name : string option;
14
39
(** Display name for sent emails. *)
15
-
email : string;
40
+
email : string option;
16
41
(** The email address to use. *)
17
42
reply_to : Mail_address.t list option;
18
43
(** Default Reply-To addresses. *)
19
44
bcc : Mail_address.t list option;
20
45
(** Default BCC addresses. *)
21
-
text_signature : string;
46
+
text_signature : string option;
22
47
(** Plain text signature. *)
23
-
html_signature : string;
48
+
html_signature : string option;
24
49
(** HTML signature. *)
25
-
may_delete : bool;
50
+
may_delete : bool option;
26
51
(** Whether the user may delete this identity. *)
27
52
}
28
53
29
-
val id : t -> Proto_id.t
30
-
val name : t -> string
31
-
val email : t -> string
54
+
val id : t -> Proto_id.t option
55
+
val name : t -> string option
56
+
val email : t -> string option
32
57
val reply_to : t -> Mail_address.t list option
33
58
val bcc : t -> Mail_address.t list option
34
-
val text_signature : t -> string
35
-
val html_signature : t -> string
36
-
val may_delete : t -> bool
59
+
val text_signature : t -> string option
60
+
val html_signature : t -> string option
61
+
val may_delete : t -> bool option
37
62
38
63
val jsont : t Jsont.t
+110
-59
lib/mail/mail_mailbox.ml
+110
-59
lib/mail/mail_mailbox.ml
···
3
3
SPDX-License-Identifier: ISC
4
4
---------------------------------------------------------------------------*)
5
5
6
+
(* Mailbox properties *)
7
+
8
+
type property = [
9
+
| `Id
10
+
| `Name
11
+
| `Parent_id
12
+
| `Role
13
+
| `Sort_order
14
+
| `Total_emails
15
+
| `Unread_emails
16
+
| `Total_threads
17
+
| `Unread_threads
18
+
| `My_rights
19
+
| `Is_subscribed
20
+
]
21
+
22
+
let property_to_string : [< property ] -> string = function
23
+
| `Id -> "id"
24
+
| `Name -> "name"
25
+
| `Parent_id -> "parentId"
26
+
| `Role -> "role"
27
+
| `Sort_order -> "sortOrder"
28
+
| `Total_emails -> "totalEmails"
29
+
| `Unread_emails -> "unreadEmails"
30
+
| `Total_threads -> "totalThreads"
31
+
| `Unread_threads -> "unreadThreads"
32
+
| `My_rights -> "myRights"
33
+
| `Is_subscribed -> "isSubscribed"
34
+
35
+
let property_of_string s : property option =
36
+
match s with
37
+
| "id" -> Some `Id
38
+
| "name" -> Some `Name
39
+
| "parentId" -> Some `Parent_id
40
+
| "role" -> Some `Role
41
+
| "sortOrder" -> Some `Sort_order
42
+
| "totalEmails" -> Some `Total_emails
43
+
| "unreadEmails" -> Some `Unread_emails
44
+
| "totalThreads" -> Some `Total_threads
45
+
| "unreadThreads" -> Some `Unread_threads
46
+
| "myRights" -> Some `My_rights
47
+
| "isSubscribed" -> Some `Is_subscribed
48
+
| _ -> None
49
+
6
50
module Rights = struct
7
51
type t = {
8
52
may_read_items : bool;
···
46
90
|> Jsont.Object.finish
47
91
end
48
92
49
-
type role =
50
-
| All
51
-
| Archive
52
-
| Drafts
53
-
| Flagged
54
-
| Important
55
-
| Inbox
56
-
| Junk
57
-
| Sent
58
-
| Subscribed
59
-
| Trash
60
-
| Other of string
93
+
type role = [
94
+
| `All
95
+
| `Archive
96
+
| `Drafts
97
+
| `Flagged
98
+
| `Important
99
+
| `Inbox
100
+
| `Junk
101
+
| `Sent
102
+
| `Subscribed
103
+
| `Trash
104
+
| `Snoozed
105
+
| `Scheduled
106
+
| `Memos
107
+
| `Other of string
108
+
]
61
109
62
110
let role_to_string = function
63
-
| All -> "all"
64
-
| Archive -> "archive"
65
-
| Drafts -> "drafts"
66
-
| Flagged -> "flagged"
67
-
| Important -> "important"
68
-
| Inbox -> "inbox"
69
-
| Junk -> "junk"
70
-
| Sent -> "sent"
71
-
| Subscribed -> "subscribed"
72
-
| Trash -> "trash"
73
-
| Other s -> s
111
+
| `All -> "all"
112
+
| `Archive -> "archive"
113
+
| `Drafts -> "drafts"
114
+
| `Flagged -> "flagged"
115
+
| `Important -> "important"
116
+
| `Inbox -> "inbox"
117
+
| `Junk -> "junk"
118
+
| `Sent -> "sent"
119
+
| `Subscribed -> "subscribed"
120
+
| `Trash -> "trash"
121
+
| `Snoozed -> "snoozed"
122
+
| `Scheduled -> "scheduled"
123
+
| `Memos -> "memos"
124
+
| `Other s -> s
74
125
75
126
let role_of_string = function
76
-
| "all" -> All
77
-
| "archive" -> Archive
78
-
| "drafts" -> Drafts
79
-
| "flagged" -> Flagged
80
-
| "important" -> Important
81
-
| "inbox" -> Inbox
82
-
| "junk" -> Junk
83
-
| "sent" -> Sent
84
-
| "subscribed" -> Subscribed
85
-
| "trash" -> Trash
86
-
| s -> Other s
127
+
| "all" -> `All
128
+
| "archive" -> `Archive
129
+
| "drafts" -> `Drafts
130
+
| "flagged" -> `Flagged
131
+
| "important" -> `Important
132
+
| "inbox" -> `Inbox
133
+
| "junk" -> `Junk
134
+
| "sent" -> `Sent
135
+
| "subscribed" -> `Subscribed
136
+
| "trash" -> `Trash
137
+
| "snoozed" -> `Snoozed
138
+
| "scheduled" -> `Scheduled
139
+
| "memos" -> `Memos
140
+
| s -> `Other s
87
141
88
142
let role_jsont =
89
143
Jsont.map ~kind:"MailboxRole"
···
92
146
Jsont.string
93
147
94
148
type t = {
95
-
id : Proto_id.t;
96
-
name : string;
149
+
id : Proto_id.t option;
150
+
name : string option;
97
151
parent_id : Proto_id.t option;
98
152
role : role option;
99
-
sort_order : int64;
100
-
total_emails : int64;
101
-
unread_emails : int64;
102
-
total_threads : int64;
103
-
unread_threads : int64;
104
-
my_rights : Rights.t;
105
-
is_subscribed : bool;
153
+
sort_order : int64 option;
154
+
total_emails : int64 option;
155
+
unread_emails : int64 option;
156
+
total_threads : int64 option;
157
+
unread_threads : int64 option;
158
+
my_rights : Rights.t option;
159
+
is_subscribed : bool option;
106
160
}
107
161
108
162
let id t = t.id
···
124
178
125
179
let jsont =
126
180
let kind = "Mailbox" in
127
-
(* parentId and role can be null - RFC 8621 Section 2 *)
128
-
let nullable_id = Jsont.(option Proto_id.jsont) in
129
-
let nullable_role = Jsont.(option role_jsont) in
130
181
Jsont.Object.map ~kind make
131
-
|> Jsont.Object.mem "id" Proto_id.jsont ~enc:id
132
-
|> Jsont.Object.mem "name" Jsont.string ~enc:name
133
-
|> Jsont.Object.mem "parentId" nullable_id
134
-
~dec_absent:None ~enc_omit:Option.is_none ~enc:parent_id
135
-
|> Jsont.Object.mem "role" nullable_role
136
-
~dec_absent:None ~enc_omit:Option.is_none ~enc:role
137
-
|> Jsont.Object.mem "sortOrder" Proto_int53.Unsigned.jsont ~dec_absent:0L ~enc:sort_order
138
-
|> Jsont.Object.mem "totalEmails" Proto_int53.Unsigned.jsont ~enc:total_emails
139
-
|> Jsont.Object.mem "unreadEmails" Proto_int53.Unsigned.jsont ~enc:unread_emails
140
-
|> Jsont.Object.mem "totalThreads" Proto_int53.Unsigned.jsont ~enc:total_threads
141
-
|> Jsont.Object.mem "unreadThreads" Proto_int53.Unsigned.jsont ~enc:unread_threads
142
-
|> Jsont.Object.mem "myRights" Rights.jsont ~enc:my_rights
143
-
|> Jsont.Object.mem "isSubscribed" Jsont.bool ~enc:is_subscribed
182
+
|> Jsont.Object.opt_mem "id" Proto_id.jsont ~enc:id
183
+
|> Jsont.Object.opt_mem "name" Jsont.string ~enc:name
184
+
(* parentId can be null meaning top-level, or absent if not requested *)
185
+
|> Jsont.Object.opt_mem "parentId" Proto_id.jsont ~enc:parent_id
186
+
(* role can be null meaning no role, or absent if not requested *)
187
+
|> Jsont.Object.opt_mem "role" role_jsont ~enc:role
188
+
|> Jsont.Object.opt_mem "sortOrder" Proto_int53.Unsigned.jsont ~enc:sort_order
189
+
|> Jsont.Object.opt_mem "totalEmails" Proto_int53.Unsigned.jsont ~enc:total_emails
190
+
|> Jsont.Object.opt_mem "unreadEmails" Proto_int53.Unsigned.jsont ~enc:unread_emails
191
+
|> Jsont.Object.opt_mem "totalThreads" Proto_int53.Unsigned.jsont ~enc:total_threads
192
+
|> Jsont.Object.opt_mem "unreadThreads" Proto_int53.Unsigned.jsont ~enc:unread_threads
193
+
|> Jsont.Object.opt_mem "myRights" Rights.jsont ~enc:my_rights
194
+
|> Jsont.Object.opt_mem "isSubscribed" Jsont.bool ~enc:is_subscribed
144
195
|> Jsont.Object.finish
145
196
146
197
module Filter_condition = struct
+66
-34
lib/mail/mail_mailbox.mli
+66
-34
lib/mail/mail_mailbox.mli
···
3
3
SPDX-License-Identifier: ISC
4
4
---------------------------------------------------------------------------*)
5
5
6
-
(** Mailbox type as defined in RFC 8621 Section 2
6
+
(** Mailbox type as defined in RFC 8621 Section 2
7
7
8
8
@canonical Jmap.Proto.Mailbox *)
9
9
10
+
(** {1 Mailbox Properties}
11
+
12
+
Polymorphic variants for type-safe property selection in Mailbox/get requests.
13
+
These correspond to the properties defined in RFC 8621 Section 2. *)
14
+
15
+
(** All Mailbox properties that can be requested. *)
16
+
type property = [
17
+
| `Id
18
+
| `Name
19
+
| `Parent_id
20
+
| `Role
21
+
| `Sort_order
22
+
| `Total_emails
23
+
| `Unread_emails
24
+
| `Total_threads
25
+
| `Unread_threads
26
+
| `My_rights
27
+
| `Is_subscribed
28
+
]
29
+
30
+
val property_to_string : [< property ] -> string
31
+
(** Convert a property to its wire name (e.g., [`Parent_id] -> "parentId"). *)
32
+
33
+
val property_of_string : string -> property option
34
+
(** Parse a property name, case-sensitive. *)
35
+
10
36
(** {1 Mailbox Rights} *)
11
37
12
38
(** Rights the user has on a mailbox. *)
···
38
64
39
65
(** {1 Standard Roles} *)
40
66
41
-
(** Standard mailbox roles per RFC 8621 Section 2. *)
42
-
type role =
43
-
| All
44
-
| Archive
45
-
| Drafts
46
-
| Flagged
47
-
| Important
48
-
| Inbox
49
-
| Junk
50
-
| Sent
51
-
| Subscribed
52
-
| Trash
53
-
| Other of string
67
+
(** Standard mailbox roles per RFC 8621 Section 2 and draft-ietf-mailmaint. *)
68
+
type role = [
69
+
| `All
70
+
| `Archive
71
+
| `Drafts
72
+
| `Flagged
73
+
| `Important
74
+
| `Inbox
75
+
| `Junk
76
+
| `Sent
77
+
| `Subscribed
78
+
| `Trash
79
+
| `Snoozed (** draft-ietf-mailmaint: Messages snoozed until a later time. *)
80
+
| `Scheduled (** draft-ietf-mailmaint: Messages scheduled to send. *)
81
+
| `Memos (** draft-ietf-mailmaint: Messages with the $memo keyword. *)
82
+
| `Other of string
83
+
]
54
84
55
85
val role_to_string : role -> string
56
86
val role_of_string : string -> role
···
59
89
(** {1 Mailbox} *)
60
90
61
91
type t = {
62
-
id : Proto_id.t;
92
+
id : Proto_id.t option;
63
93
(** Server-assigned mailbox id. *)
64
-
name : string;
94
+
name : string option;
65
95
(** User-visible name (UTF-8). *)
66
96
parent_id : Proto_id.t option;
67
-
(** Id of parent mailbox, or [None] for root. *)
97
+
(** Id of parent mailbox, or [None] for root. Note: [None] can mean
98
+
either "not requested" or "top-level mailbox". *)
68
99
role : role option;
69
-
(** Standard role, if any. *)
70
-
sort_order : int64;
100
+
(** Standard role, if any. Note: [None] can mean either "not requested"
101
+
or "no role assigned". *)
102
+
sort_order : int64 option;
71
103
(** Sort order hint (lower = displayed first). *)
72
-
total_emails : int64;
104
+
total_emails : int64 option;
73
105
(** Total number of emails in mailbox. *)
74
-
unread_emails : int64;
106
+
unread_emails : int64 option;
75
107
(** Number of unread emails. *)
76
-
total_threads : int64;
108
+
total_threads : int64 option;
77
109
(** Total number of threads. *)
78
-
unread_threads : int64;
110
+
unread_threads : int64 option;
79
111
(** Number of threads with unread emails. *)
80
-
my_rights : Rights.t;
112
+
my_rights : Rights.t option;
81
113
(** User's rights on this mailbox. *)
82
-
is_subscribed : bool;
114
+
is_subscribed : bool option;
83
115
(** Whether user is subscribed to this mailbox. *)
84
116
}
85
117
86
-
val id : t -> Proto_id.t
87
-
val name : t -> string
118
+
val id : t -> Proto_id.t option
119
+
val name : t -> string option
88
120
val parent_id : t -> Proto_id.t option
89
121
val role : t -> role option
90
-
val sort_order : t -> int64
91
-
val total_emails : t -> int64
92
-
val unread_emails : t -> int64
93
-
val total_threads : t -> int64
94
-
val unread_threads : t -> int64
95
-
val my_rights : t -> Rights.t
96
-
val is_subscribed : t -> bool
122
+
val sort_order : t -> int64 option
123
+
val total_emails : t -> int64 option
124
+
val unread_emails : t -> int64 option
125
+
val total_threads : t -> int64 option
126
+
val unread_threads : t -> int64 option
127
+
val my_rights : t -> Rights.t option
128
+
val is_subscribed : t -> bool option
97
129
98
130
val jsont : t Jsont.t
99
131
+78
-37
lib/mail/mail_submission.ml
+78
-37
lib/mail/mail_submission.ml
···
3
3
SPDX-License-Identifier: ISC
4
4
---------------------------------------------------------------------------*)
5
5
6
+
(* EmailSubmission properties *)
7
+
8
+
type property = [
9
+
| `Id
10
+
| `Identity_id
11
+
| `Email_id
12
+
| `Thread_id
13
+
| `Envelope
14
+
| `Send_at
15
+
| `Undo_status
16
+
| `Delivery_status
17
+
| `Dsn_blob_ids
18
+
| `Mdn_blob_ids
19
+
]
20
+
21
+
let property_to_string : [< property ] -> string = function
22
+
| `Id -> "id"
23
+
| `Identity_id -> "identityId"
24
+
| `Email_id -> "emailId"
25
+
| `Thread_id -> "threadId"
26
+
| `Envelope -> "envelope"
27
+
| `Send_at -> "sendAt"
28
+
| `Undo_status -> "undoStatus"
29
+
| `Delivery_status -> "deliveryStatus"
30
+
| `Dsn_blob_ids -> "dsnBlobIds"
31
+
| `Mdn_blob_ids -> "mdnBlobIds"
32
+
33
+
let property_of_string s : property option =
34
+
match s with
35
+
| "id" -> Some `Id
36
+
| "identityId" -> Some `Identity_id
37
+
| "emailId" -> Some `Email_id
38
+
| "threadId" -> Some `Thread_id
39
+
| "envelope" -> Some `Envelope
40
+
| "sendAt" -> Some `Send_at
41
+
| "undoStatus" -> Some `Undo_status
42
+
| "deliveryStatus" -> Some `Delivery_status
43
+
| "dsnBlobIds" -> Some `Dsn_blob_ids
44
+
| "mdnBlobIds" -> Some `Mdn_blob_ids
45
+
| _ -> None
46
+
6
47
module Address = struct
7
48
type t = {
8
49
email : string;
···
42
83
end
43
84
44
85
module Delivery_status = struct
45
-
type delivered = Queued | Yes | No | Unknown
86
+
type delivered = [ `Queued | `Yes | `No | `Unknown ]
46
87
47
88
let delivered_to_string = function
48
-
| Queued -> "queued"
49
-
| Yes -> "yes"
50
-
| No -> "no"
51
-
| Unknown -> "unknown"
89
+
| `Queued -> "queued"
90
+
| `Yes -> "yes"
91
+
| `No -> "no"
92
+
| `Unknown -> "unknown"
52
93
53
94
let delivered_of_string = function
54
-
| "queued" -> Queued
55
-
| "yes" -> Yes
56
-
| "no" -> No
57
-
| _ -> Unknown
95
+
| "queued" -> `Queued
96
+
| "yes" -> `Yes
97
+
| "no" -> `No
98
+
| _ -> `Unknown
58
99
59
100
let delivered_jsont =
60
101
Jsont.map ~kind:"DeliveryStatus.delivered"
61
102
~dec:delivered_of_string ~enc:delivered_to_string Jsont.string
62
103
63
-
type displayed = Unknown | Yes
104
+
type displayed = [ `Unknown | `Yes ]
64
105
65
106
let displayed_to_string = function
66
-
| Unknown -> "unknown"
67
-
| Yes -> "yes"
107
+
| `Unknown -> "unknown"
108
+
| `Yes -> "yes"
68
109
69
110
let displayed_of_string = function
70
-
| "yes" -> Yes
71
-
| _ -> Unknown
111
+
| "yes" -> `Yes
112
+
| _ -> `Unknown
72
113
73
114
let displayed_jsont =
74
115
Jsont.map ~kind:"DeliveryStatus.displayed"
···
96
137
|> Jsont.Object.finish
97
138
end
98
139
99
-
type undo_status = Pending | Final | Canceled
140
+
type undo_status = [ `Pending | `Final | `Canceled ]
100
141
101
142
let undo_status_to_string = function
102
-
| Pending -> "pending"
103
-
| Final -> "final"
104
-
| Canceled -> "canceled"
143
+
| `Pending -> "pending"
144
+
| `Final -> "final"
145
+
| `Canceled -> "canceled"
105
146
106
147
let undo_status_of_string = function
107
-
| "pending" -> Pending
108
-
| "final" -> Final
109
-
| "canceled" -> Canceled
148
+
| "pending" -> `Pending
149
+
| "final" -> `Final
150
+
| "canceled" -> `Canceled
110
151
| s -> Jsont.Error.msgf Jsont.Meta.none "Unknown undo status: %s" s
111
152
112
153
let undo_status_jsont =
···
114
155
~dec:undo_status_of_string ~enc:undo_status_to_string Jsont.string
115
156
116
157
type t = {
117
-
id : Proto_id.t;
118
-
identity_id : Proto_id.t;
119
-
email_id : Proto_id.t;
120
-
thread_id : Proto_id.t;
158
+
id : Proto_id.t option;
159
+
identity_id : Proto_id.t option;
160
+
email_id : Proto_id.t option;
161
+
thread_id : Proto_id.t option;
121
162
envelope : Envelope.t option;
122
-
send_at : Ptime.t;
123
-
undo_status : undo_status;
163
+
send_at : Ptime.t option;
164
+
undo_status : undo_status option;
124
165
delivery_status : (string * Delivery_status.t) list option;
125
-
dsn_blob_ids : Proto_id.t list;
126
-
mdn_blob_ids : Proto_id.t list;
166
+
dsn_blob_ids : Proto_id.t list option;
167
+
mdn_blob_ids : Proto_id.t list option;
127
168
}
128
169
129
170
let id t = t.id
···
145
186
let jsont =
146
187
let kind = "EmailSubmission" in
147
188
Jsont.Object.map ~kind make
148
-
|> Jsont.Object.mem "id" Proto_id.jsont ~enc:id
149
-
|> Jsont.Object.mem "identityId" Proto_id.jsont ~enc:identity_id
150
-
|> Jsont.Object.mem "emailId" Proto_id.jsont ~enc:email_id
151
-
|> Jsont.Object.mem "threadId" Proto_id.jsont ~enc:thread_id
189
+
|> Jsont.Object.opt_mem "id" Proto_id.jsont ~enc:id
190
+
|> Jsont.Object.opt_mem "identityId" Proto_id.jsont ~enc:identity_id
191
+
|> Jsont.Object.opt_mem "emailId" Proto_id.jsont ~enc:email_id
192
+
|> Jsont.Object.opt_mem "threadId" Proto_id.jsont ~enc:thread_id
152
193
|> Jsont.Object.opt_mem "envelope" Envelope.jsont ~enc:envelope
153
-
|> Jsont.Object.mem "sendAt" Proto_date.Utc.jsont ~enc:send_at
154
-
|> Jsont.Object.mem "undoStatus" undo_status_jsont ~enc:undo_status
194
+
|> Jsont.Object.opt_mem "sendAt" Proto_date.Utc.jsont ~enc:send_at
195
+
|> Jsont.Object.opt_mem "undoStatus" undo_status_jsont ~enc:undo_status
155
196
|> Jsont.Object.opt_mem "deliveryStatus" (Proto_json_map.of_string Delivery_status.jsont) ~enc:delivery_status
156
-
|> Jsont.Object.mem "dsnBlobIds" (Jsont.list Proto_id.jsont) ~dec_absent:[] ~enc:dsn_blob_ids
157
-
|> Jsont.Object.mem "mdnBlobIds" (Jsont.list Proto_id.jsont) ~dec_absent:[] ~enc:mdn_blob_ids
197
+
|> Jsont.Object.opt_mem "dsnBlobIds" (Jsont.list Proto_id.jsont) ~enc:dsn_blob_ids
198
+
|> Jsont.Object.opt_mem "mdnBlobIds" (Jsont.list Proto_id.jsont) ~enc:mdn_blob_ids
158
199
|> Jsont.Object.finish
159
200
160
201
module Filter_condition = struct
+57
-29
lib/mail/mail_submission.mli
+57
-29
lib/mail/mail_submission.mli
···
3
3
SPDX-License-Identifier: ISC
4
4
---------------------------------------------------------------------------*)
5
5
6
-
(** EmailSubmission type as defined in RFC 8621 Section 7
6
+
(** EmailSubmission type as defined in RFC 8621 Section 7
7
7
8
8
@canonical Jmap.Proto.Submission *)
9
+
10
+
(** {1 EmailSubmission Properties}
11
+
12
+
Polymorphic variants for type-safe property selection in EmailSubmission/get requests.
13
+
These correspond to the properties defined in RFC 8621 Section 7. *)
14
+
15
+
(** All EmailSubmission properties that can be requested. *)
16
+
type property = [
17
+
| `Id
18
+
| `Identity_id
19
+
| `Email_id
20
+
| `Thread_id
21
+
| `Envelope
22
+
| `Send_at
23
+
| `Undo_status
24
+
| `Delivery_status
25
+
| `Dsn_blob_ids
26
+
| `Mdn_blob_ids
27
+
]
28
+
29
+
val property_to_string : [< property ] -> string
30
+
(** Convert a property to its wire name (e.g., [`Identity_id] -> "identityId"). *)
31
+
32
+
val property_of_string : string -> property option
33
+
(** Parse a property name, case-sensitive. *)
9
34
10
35
(** {1 Address} *)
11
36
···
45
70
46
71
(** Status of delivery to a recipient. *)
47
72
module Delivery_status : sig
48
-
type delivered =
49
-
| Queued
50
-
| Yes
51
-
| No
52
-
| Unknown
73
+
type delivered = [
74
+
| `Queued
75
+
| `Yes
76
+
| `No
77
+
| `Unknown
78
+
]
53
79
54
-
type displayed =
55
-
| Unknown
56
-
| Yes
80
+
type displayed = [
81
+
| `Unknown
82
+
| `Yes
83
+
]
57
84
58
85
type t = {
59
86
smtp_reply : string;
···
73
100
74
101
(** {1 Undo Status} *)
75
102
76
-
type undo_status =
77
-
| Pending
78
-
| Final
79
-
| Canceled
103
+
type undo_status = [
104
+
| `Pending
105
+
| `Final
106
+
| `Canceled
107
+
]
80
108
81
109
val undo_status_jsont : undo_status Jsont.t
82
110
83
111
(** {1 EmailSubmission} *)
84
112
85
113
type t = {
86
-
id : Proto_id.t;
114
+
id : Proto_id.t option;
87
115
(** Server-assigned submission id. *)
88
-
identity_id : Proto_id.t;
116
+
identity_id : Proto_id.t option;
89
117
(** The identity used to send. *)
90
-
email_id : Proto_id.t;
118
+
email_id : Proto_id.t option;
91
119
(** The email that was submitted. *)
92
-
thread_id : Proto_id.t;
120
+
thread_id : Proto_id.t option;
93
121
(** The thread of the submitted email. *)
94
122
envelope : Envelope.t option;
95
123
(** The envelope used, if different from email headers. *)
96
-
send_at : Ptime.t;
124
+
send_at : Ptime.t option;
97
125
(** When the email was/will be sent. *)
98
-
undo_status : undo_status;
126
+
undo_status : undo_status option;
99
127
(** Whether sending can be undone. *)
100
128
delivery_status : (string * Delivery_status.t) list option;
101
129
(** Delivery status per recipient. *)
102
-
dsn_blob_ids : Proto_id.t list;
130
+
dsn_blob_ids : Proto_id.t list option;
103
131
(** Blob ids of received DSN messages. *)
104
-
mdn_blob_ids : Proto_id.t list;
132
+
mdn_blob_ids : Proto_id.t list option;
105
133
(** Blob ids of received MDN messages. *)
106
134
}
107
135
108
-
val id : t -> Proto_id.t
109
-
val identity_id : t -> Proto_id.t
110
-
val email_id : t -> Proto_id.t
111
-
val thread_id : t -> Proto_id.t
136
+
val id : t -> Proto_id.t option
137
+
val identity_id : t -> Proto_id.t option
138
+
val email_id : t -> Proto_id.t option
139
+
val thread_id : t -> Proto_id.t option
112
140
val envelope : t -> Envelope.t option
113
-
val send_at : t -> Ptime.t
114
-
val undo_status : t -> undo_status
141
+
val send_at : t -> Ptime.t option
142
+
val undo_status : t -> undo_status option
115
143
val delivery_status : t -> (string * Delivery_status.t) list option
116
-
val dsn_blob_ids : t -> Proto_id.t list
117
-
val mdn_blob_ids : t -> Proto_id.t list
144
+
val dsn_blob_ids : t -> Proto_id.t list option
145
+
val mdn_blob_ids : t -> Proto_id.t list option
118
146
119
147
val jsont : t Jsont.t
120
148
+23
-4
lib/mail/mail_thread.ml
+23
-4
lib/mail/mail_thread.ml
···
3
3
SPDX-License-Identifier: ISC
4
4
---------------------------------------------------------------------------*)
5
5
6
+
(* Thread properties *)
7
+
8
+
type property = [
9
+
| `Id
10
+
| `Email_ids
11
+
]
12
+
13
+
let property_to_string : [< property ] -> string = function
14
+
| `Id -> "id"
15
+
| `Email_ids -> "emailIds"
16
+
17
+
let property_of_string s : property option =
18
+
match s with
19
+
| "id" -> Some `Id
20
+
| "emailIds" -> Some `Email_ids
21
+
| _ -> None
22
+
23
+
(* Thread type *)
24
+
6
25
type t = {
7
-
id : Proto_id.t;
8
-
email_ids : Proto_id.t list;
26
+
id : Proto_id.t option;
27
+
email_ids : Proto_id.t list option;
9
28
}
10
29
11
30
let id t = t.id
···
16
35
let jsont =
17
36
let kind = "Thread" in
18
37
Jsont.Object.map ~kind make
19
-
|> Jsont.Object.mem "id" Proto_id.jsont ~enc:id
20
-
|> Jsont.Object.mem "emailIds" (Jsont.list Proto_id.jsont) ~enc:email_ids
38
+
|> Jsont.Object.opt_mem "id" Proto_id.jsont ~enc:id
39
+
|> Jsont.Object.opt_mem "emailIds" (Jsont.list Proto_id.jsont) ~enc:email_ids
21
40
|> Jsont.Object.finish
+24
-5
lib/mail/mail_thread.mli
+24
-5
lib/mail/mail_thread.mli
···
3
3
SPDX-License-Identifier: ISC
4
4
---------------------------------------------------------------------------*)
5
5
6
-
(** Thread type as defined in RFC 8621 Section 3
6
+
(** Thread type as defined in RFC 8621 Section 3
7
7
8
8
@canonical Jmap.Proto.Thread *)
9
9
10
+
(** {1 Thread Properties}
11
+
12
+
Polymorphic variants for type-safe property selection in Thread/get requests.
13
+
Threads have only two properties per RFC 8621 Section 3. *)
14
+
15
+
(** All Thread properties that can be requested. *)
16
+
type property = [
17
+
| `Id
18
+
| `Email_ids
19
+
]
20
+
21
+
val property_to_string : [< property ] -> string
22
+
(** Convert a property to its wire name (e.g., [`Email_ids] -> "emailIds"). *)
23
+
24
+
val property_of_string : string -> property option
25
+
(** Parse a property name, case-sensitive. *)
26
+
27
+
(** {1 Thread Object} *)
28
+
10
29
type t = {
11
-
id : Proto_id.t;
30
+
id : Proto_id.t option;
12
31
(** Server-assigned thread id. *)
13
-
email_ids : Proto_id.t list;
32
+
email_ids : Proto_id.t list option;
14
33
(** Ids of emails in this thread, in date order. *)
15
34
}
16
35
17
-
val id : t -> Proto_id.t
18
-
val email_ids : t -> Proto_id.t list
36
+
val id : t -> Proto_id.t option
37
+
val email_ids : t -> Proto_id.t list option
19
38
20
39
val jsont : t Jsont.t
+93
-81
lib/proto/proto_error.ml
+93
-81
lib/proto/proto_error.ml
···
4
4
---------------------------------------------------------------------------*)
5
5
6
6
module Request_error = struct
7
-
type urn =
8
-
| Unknown_capability
9
-
| Not_json
10
-
| Not_request
11
-
| Limit
12
-
| Other of string
7
+
type urn = [
8
+
| `Unknown_capability
9
+
| `Not_json
10
+
| `Not_request
11
+
| `Limit
12
+
| `Other of string
13
+
]
13
14
14
15
let urn_to_string = function
15
-
| Unknown_capability -> "urn:ietf:params:jmap:error:unknownCapability"
16
-
| Not_json -> "urn:ietf:params:jmap:error:notJSON"
17
-
| Not_request -> "urn:ietf:params:jmap:error:notRequest"
18
-
| Limit -> "urn:ietf:params:jmap:error:limit"
19
-
| Other s -> s
16
+
| `Unknown_capability -> "urn:ietf:params:jmap:error:unknownCapability"
17
+
| `Not_json -> "urn:ietf:params:jmap:error:notJSON"
18
+
| `Not_request -> "urn:ietf:params:jmap:error:notRequest"
19
+
| `Limit -> "urn:ietf:params:jmap:error:limit"
20
+
| `Other s -> s
20
21
21
22
let urn_of_string = function
22
-
| "urn:ietf:params:jmap:error:unknownCapability" -> Unknown_capability
23
-
| "urn:ietf:params:jmap:error:notJSON" -> Not_json
24
-
| "urn:ietf:params:jmap:error:notRequest" -> Not_request
25
-
| "urn:ietf:params:jmap:error:limit" -> Limit
26
-
| s -> Other s
23
+
| "urn:ietf:params:jmap:error:unknownCapability" -> `Unknown_capability
24
+
| "urn:ietf:params:jmap:error:notJSON" -> `Not_json
25
+
| "urn:ietf:params:jmap:error:notRequest" -> `Not_request
26
+
| "urn:ietf:params:jmap:error:limit" -> `Limit
27
+
| s -> `Other s
27
28
28
29
let urn_jsont =
29
30
let kind = "Request error URN" in
···
60
61
|> Jsont.Object.finish
61
62
end
62
63
63
-
type method_error_type =
64
-
| Server_unavailable
65
-
| Server_fail
66
-
| Server_partial_fail
67
-
| Unknown_method
68
-
| Invalid_arguments
69
-
| Invalid_result_reference
70
-
| Forbidden
71
-
| Account_not_found
72
-
| Account_not_supported_by_method
73
-
| Account_read_only
74
-
| Other of string
64
+
type method_error_type = [
65
+
| `Server_unavailable
66
+
| `Server_fail
67
+
| `Server_partial_fail
68
+
| `Unknown_method
69
+
| `Invalid_arguments
70
+
| `Invalid_result_reference
71
+
| `Forbidden
72
+
| `Account_not_found
73
+
| `Account_not_supported_by_method
74
+
| `Account_read_only
75
+
| `Other of string
76
+
]
75
77
76
78
let method_error_type_to_string = function
77
-
| Server_unavailable -> "serverUnavailable"
78
-
| Server_fail -> "serverFail"
79
-
| Server_partial_fail -> "serverPartialFail"
80
-
| Unknown_method -> "unknownMethod"
81
-
| Invalid_arguments -> "invalidArguments"
82
-
| Invalid_result_reference -> "invalidResultReference"
83
-
| Forbidden -> "forbidden"
84
-
| Account_not_found -> "accountNotFound"
85
-
| Account_not_supported_by_method -> "accountNotSupportedByMethod"
86
-
| Account_read_only -> "accountReadOnly"
87
-
| Other s -> s
79
+
| `Server_unavailable -> "serverUnavailable"
80
+
| `Server_fail -> "serverFail"
81
+
| `Server_partial_fail -> "serverPartialFail"
82
+
| `Unknown_method -> "unknownMethod"
83
+
| `Invalid_arguments -> "invalidArguments"
84
+
| `Invalid_result_reference -> "invalidResultReference"
85
+
| `Forbidden -> "forbidden"
86
+
| `Account_not_found -> "accountNotFound"
87
+
| `Account_not_supported_by_method -> "accountNotSupportedByMethod"
88
+
| `Account_read_only -> "accountReadOnly"
89
+
| `Other s -> s
88
90
89
91
let method_error_type_of_string = function
90
-
| "serverUnavailable" -> Server_unavailable
91
-
| "serverFail" -> Server_fail
92
-
| "serverPartialFail" -> Server_partial_fail
93
-
| "unknownMethod" -> Unknown_method
94
-
| "invalidArguments" -> Invalid_arguments
95
-
| "invalidResultReference" -> Invalid_result_reference
96
-
| "forbidden" -> Forbidden
97
-
| "accountNotFound" -> Account_not_found
98
-
| "accountNotSupportedByMethod" -> Account_not_supported_by_method
99
-
| "accountReadOnly" -> Account_read_only
100
-
| s -> Other s
92
+
| "serverUnavailable" -> `Server_unavailable
93
+
| "serverFail" -> `Server_fail
94
+
| "serverPartialFail" -> `Server_partial_fail
95
+
| "unknownMethod" -> `Unknown_method
96
+
| "invalidArguments" -> `Invalid_arguments
97
+
| "invalidResultReference" -> `Invalid_result_reference
98
+
| "forbidden" -> `Forbidden
99
+
| "accountNotFound" -> `Account_not_found
100
+
| "accountNotSupportedByMethod" -> `Account_not_supported_by_method
101
+
| "accountReadOnly" -> `Account_read_only
102
+
| s -> `Other s
101
103
102
104
let method_error_type_jsont =
103
105
let kind = "Method error type" in
···
122
124
|> Jsont.Object.opt_mem "description" Jsont.string ~enc:method_error_description
123
125
|> Jsont.Object.finish
124
126
125
-
type set_error_type =
126
-
| Forbidden
127
-
| Over_quota
128
-
| Too_large
129
-
| Rate_limit
130
-
| Not_found
131
-
| Invalid_patch
132
-
| Will_destroy
133
-
| Invalid_properties
134
-
| Singleton
135
-
| Other of string
127
+
type set_error_type = [
128
+
| `Forbidden
129
+
| `Over_quota
130
+
| `Too_large
131
+
| `Rate_limit
132
+
| `Not_found
133
+
| `Invalid_patch
134
+
| `Will_destroy
135
+
| `Invalid_properties
136
+
| `Singleton
137
+
| `Forbidden_mail_from
138
+
| `Forbidden_from
139
+
| `Forbidden_to_send
140
+
| `Other of string
141
+
]
136
142
137
143
let set_error_type_to_string = function
138
-
| Forbidden -> "forbidden"
139
-
| Over_quota -> "overQuota"
140
-
| Too_large -> "tooLarge"
141
-
| Rate_limit -> "rateLimit"
142
-
| Not_found -> "notFound"
143
-
| Invalid_patch -> "invalidPatch"
144
-
| Will_destroy -> "willDestroy"
145
-
| Invalid_properties -> "invalidProperties"
146
-
| Singleton -> "singleton"
147
-
| Other s -> s
144
+
| `Forbidden -> "forbidden"
145
+
| `Over_quota -> "overQuota"
146
+
| `Too_large -> "tooLarge"
147
+
| `Rate_limit -> "rateLimit"
148
+
| `Not_found -> "notFound"
149
+
| `Invalid_patch -> "invalidPatch"
150
+
| `Will_destroy -> "willDestroy"
151
+
| `Invalid_properties -> "invalidProperties"
152
+
| `Singleton -> "singleton"
153
+
| `Forbidden_mail_from -> "forbiddenMailFrom"
154
+
| `Forbidden_from -> "forbiddenFrom"
155
+
| `Forbidden_to_send -> "forbiddenToSend"
156
+
| `Other s -> s
148
157
149
158
let set_error_type_of_string = function
150
-
| "forbidden" -> Forbidden
151
-
| "overQuota" -> Over_quota
152
-
| "tooLarge" -> Too_large
153
-
| "rateLimit" -> Rate_limit
154
-
| "notFound" -> Not_found
155
-
| "invalidPatch" -> Invalid_patch
156
-
| "willDestroy" -> Will_destroy
157
-
| "invalidProperties" -> Invalid_properties
158
-
| "singleton" -> Singleton
159
-
| s -> Other s
159
+
| "forbidden" -> `Forbidden
160
+
| "overQuota" -> `Over_quota
161
+
| "tooLarge" -> `Too_large
162
+
| "rateLimit" -> `Rate_limit
163
+
| "notFound" -> `Not_found
164
+
| "invalidPatch" -> `Invalid_patch
165
+
| "willDestroy" -> `Will_destroy
166
+
| "invalidProperties" -> `Invalid_properties
167
+
| "singleton" -> `Singleton
168
+
| "forbiddenMailFrom" -> `Forbidden_mail_from
169
+
| "forbiddenFrom" -> `Forbidden_from
170
+
| "forbiddenToSend" -> `Forbidden_to_send
171
+
| s -> `Other s
160
172
161
173
let set_error_type_jsont =
162
174
let kind = "SetError type" in
+40
-30
lib/proto/proto_error.mli
+40
-30
lib/proto/proto_error.mli
···
14
14
15
15
(** Request-level error URNs *)
16
16
module Request_error : sig
17
-
type urn =
18
-
| Unknown_capability
17
+
type urn = [
18
+
| `Unknown_capability
19
19
(** urn:ietf:params:jmap:error:unknownCapability
20
20
The client included a capability in "using" that the server does not support. *)
21
-
| Not_json
21
+
| `Not_json
22
22
(** urn:ietf:params:jmap:error:notJSON
23
23
The content type was not application/json or the request was not valid JSON. *)
24
-
| Not_request
24
+
| `Not_request
25
25
(** urn:ietf:params:jmap:error:notRequest
26
26
The request was valid JSON but not a valid JMAP Request object. *)
27
-
| Limit
27
+
| `Limit
28
28
(** urn:ietf:params:jmap:error:limit
29
29
A server-defined limit was reached. *)
30
-
| Other of string
30
+
| `Other of string
31
31
(** Other URN not in the standard set. *)
32
+
]
32
33
33
34
val urn_to_string : urn -> string
34
35
(** [urn_to_string urn] returns the URN string. *)
···
60
61
when a method call fails. *)
61
62
62
63
(** Standard method error types per RFC 8620 Section 3.6.2 *)
63
-
type method_error_type =
64
-
| Server_unavailable
64
+
type method_error_type = [
65
+
| `Server_unavailable
65
66
(** The server is temporarily unavailable. *)
66
-
| Server_fail
67
+
| `Server_fail
67
68
(** An unexpected error occurred. *)
68
-
| Server_partial_fail
69
+
| `Server_partial_fail
69
70
(** Some, but not all, changes were successfully made. *)
70
-
| Unknown_method
71
+
| `Unknown_method
71
72
(** The method name is not recognized. *)
72
-
| Invalid_arguments
73
+
| `Invalid_arguments
73
74
(** One or more arguments are invalid. *)
74
-
| Invalid_result_reference
75
+
| `Invalid_result_reference
75
76
(** A result reference could not be resolved. *)
76
-
| Forbidden
77
+
| `Forbidden
77
78
(** The method/arguments are valid but forbidden. *)
78
-
| Account_not_found
79
+
| `Account_not_found
79
80
(** The accountId does not correspond to a valid account. *)
80
-
| Account_not_supported_by_method
81
+
| `Account_not_supported_by_method
81
82
(** The account does not support this method. *)
82
-
| Account_read_only
83
+
| `Account_read_only
83
84
(** The account is read-only. *)
84
-
| Other of string
85
+
| `Other of string
85
86
(** Other error type not in the standard set. *)
87
+
]
86
88
87
89
val method_error_type_to_string : method_error_type -> string
88
90
(** [method_error_type_to_string t] returns the type string. *)
···
105
107
106
108
Errors returned in notCreated/notUpdated/notDestroyed responses. *)
107
109
108
-
(** Standard SetError types per RFC 8620 Section 5.3 *)
109
-
type set_error_type =
110
-
| Forbidden
110
+
(** Standard SetError types per RFC 8620 Section 5.3 and RFC 8621 Section 7 *)
111
+
type set_error_type = [
112
+
| `Forbidden
111
113
(** The operation is not permitted. *)
112
-
| Over_quota
114
+
| `Over_quota
113
115
(** The maximum server quota has been reached. *)
114
-
| Too_large
116
+
| `Too_large
115
117
(** The object is too large. *)
116
-
| Rate_limit
118
+
| `Rate_limit
117
119
(** Too many objects of this type have been created recently. *)
118
-
| Not_found
120
+
| `Not_found
119
121
(** The id does not exist (for update/destroy). *)
120
-
| Invalid_patch
122
+
| `Invalid_patch
121
123
(** The PatchObject is invalid. *)
122
-
| Will_destroy
124
+
| `Will_destroy
123
125
(** The object will be destroyed by another operation in the request. *)
124
-
| Invalid_properties
126
+
| `Invalid_properties
125
127
(** Some properties were invalid. *)
126
-
| Singleton
128
+
| `Singleton
127
129
(** Only one object of this type can exist (for create). *)
128
-
| Other of string
130
+
| `Forbidden_mail_from
131
+
(** RFC 8621: The server does not permit the user to send from the address. *)
132
+
| `Forbidden_from
133
+
(** RFC 8621: The server does not permit the user to send a message with
134
+
the From header of the message to be sent. *)
135
+
| `Forbidden_to_send
136
+
(** RFC 8621: The user does not have permission to send at all. *)
137
+
| `Other of string
129
138
(** Other error type. *)
139
+
]
130
140
131
141
val set_error_type_to_string : set_error_type -> string
132
142
val set_error_type_of_string : string -> set_error_type
+7
-7
lib/proto/proto_filter.ml
+7
-7
lib/proto/proto_filter.ml
···
3
3
SPDX-License-Identifier: ISC
4
4
---------------------------------------------------------------------------*)
5
5
6
-
type operator = And | Or | Not
6
+
type operator = [ `And | `Or | `Not ]
7
7
8
8
let operator_to_string = function
9
-
| And -> "AND"
10
-
| Or -> "OR"
11
-
| Not -> "NOT"
9
+
| `And -> "AND"
10
+
| `Or -> "OR"
11
+
| `Not -> "NOT"
12
12
13
13
let operator_of_string = function
14
-
| "AND" -> And
15
-
| "OR" -> Or
16
-
| "NOT" -> Not
14
+
| "AND" -> `And
15
+
| "OR" -> `Or
16
+
| "NOT" -> `Not
17
17
| s -> Jsont.Error.msgf Jsont.Meta.none "Unknown filter operator: %s" s
18
18
19
19
let operator_jsont =
+5
-4
lib/proto/proto_filter.mli
+5
-4
lib/proto/proto_filter.mli
···
10
10
(** {1 Filter Operators} *)
11
11
12
12
(** Filter operator types. *)
13
-
type operator =
14
-
| And (** All conditions must match *)
15
-
| Or (** At least one condition must match *)
16
-
| Not (** Inverts a single condition *)
13
+
type operator = [
14
+
| `And (** All conditions must match *)
15
+
| `Or (** At least one condition must match *)
16
+
| `Not (** Inverts a single condition *)
17
+
]
17
18
18
19
val operator_jsont : operator Jsont.t
19
20
(** JSON codec for filter operators. *)
+18
-8
lib/proto/proto_method.ml
+18
-8
lib/proto/proto_method.ml
···
132
132
133
133
let set_response_jsont (type a) (obj_jsont : a Jsont.t) : a set_response Jsont.t =
134
134
let kind = "SetResponse" in
135
+
(* All map/list fields in SetResponse can be null per RFC 8620 Section 5.3 *)
136
+
(* opt_mem handles missing keys, Jsont.option handles explicit null values *)
137
+
(* Option.join flattens option option -> option *)
138
+
let join = Option.join in
135
139
let make account_id old_state new_state created updated destroyed
136
140
not_created not_updated not_destroyed =
137
-
{ account_id; old_state; new_state; created; updated; destroyed;
138
-
not_created; not_updated; not_destroyed }
141
+
{ account_id; old_state; new_state;
142
+
created = join created;
143
+
updated = join updated;
144
+
destroyed = join destroyed;
145
+
not_created = join not_created;
146
+
not_updated = join not_updated;
147
+
not_destroyed = join not_destroyed }
139
148
in
140
149
(* For updated values, the server may return null or an object - RFC 8620 Section 5.3 *)
141
150
(* "Id[Foo|null]" means map values can be null, use Jsont.option to handle this *)
142
151
let nullable_obj = Jsont.(option obj_jsont) in
152
+
let opt enc = Option.map Option.some enc in
143
153
Jsont.Object.map ~kind make
144
154
|> Jsont.Object.mem "accountId" Proto_id.jsont ~enc:(fun r -> r.account_id)
145
155
|> Jsont.Object.opt_mem "oldState" Jsont.string ~enc:(fun r -> r.old_state)
146
156
|> Jsont.Object.mem "newState" Jsont.string ~enc:(fun r -> r.new_state)
147
-
|> Jsont.Object.opt_mem "created" (Proto_json_map.of_id obj_jsont) ~enc:(fun r -> r.created)
148
-
|> Jsont.Object.opt_mem "updated" (Proto_json_map.of_id nullable_obj) ~enc:(fun r -> r.updated)
149
-
|> Jsont.Object.opt_mem "destroyed" (Jsont.list Proto_id.jsont) ~enc:(fun r -> r.destroyed)
150
-
|> Jsont.Object.opt_mem "notCreated" (Proto_json_map.of_id Proto_error.set_error_jsont) ~enc:(fun r -> r.not_created)
151
-
|> Jsont.Object.opt_mem "notUpdated" (Proto_json_map.of_id Proto_error.set_error_jsont) ~enc:(fun r -> r.not_updated)
152
-
|> Jsont.Object.opt_mem "notDestroyed" (Proto_json_map.of_id Proto_error.set_error_jsont) ~enc:(fun r -> r.not_destroyed)
157
+
|> Jsont.Object.opt_mem "created" Jsont.(option (Proto_json_map.of_id obj_jsont)) ~enc:(fun r -> opt r.created)
158
+
|> Jsont.Object.opt_mem "updated" Jsont.(option (Proto_json_map.of_id nullable_obj)) ~enc:(fun r -> opt r.updated)
159
+
|> Jsont.Object.opt_mem "destroyed" Jsont.(option (list Proto_id.jsont)) ~enc:(fun r -> opt r.destroyed)
160
+
|> Jsont.Object.opt_mem "notCreated" Jsont.(option (Proto_json_map.of_id Proto_error.set_error_jsont)) ~enc:(fun r -> opt r.not_created)
161
+
|> Jsont.Object.opt_mem "notUpdated" Jsont.(option (Proto_json_map.of_id Proto_error.set_error_jsont)) ~enc:(fun r -> opt r.not_updated)
162
+
|> Jsont.Object.opt_mem "notDestroyed" Jsont.(option (Proto_json_map.of_id Proto_error.set_error_jsont)) ~enc:(fun r -> opt r.not_destroyed)
153
163
|> Jsont.Object.finish
154
164
155
165
(* Foo/copy *)
+6
lib/top/dune
+6
lib/top/dune
+68
lib/top/jmap_top.ml
+68
lib/top/jmap_top.ml
···
1
+
(* Toplevel printers for JMAP types
2
+
3
+
Usage in toplevel:
4
+
#require "jmap.top";;
5
+
6
+
Printers are automatically installed when the library is loaded.
7
+
*)
8
+
9
+
(* JSON printers *)
10
+
11
+
let json_printer ppf (json : Jsont.json) =
12
+
match Jsont_bytesrw.encode_string Jsont.json json with
13
+
| Ok s -> Format.pp_print_string ppf s
14
+
| Error e -> Format.fprintf ppf "<json encoding error: %s>" e
15
+
16
+
let jsont_error_printer ppf (e : Jsont.Error.t) =
17
+
Format.pp_print_string ppf (Jsont.Error.to_string e)
18
+
19
+
(* JSON encoding helpers *)
20
+
21
+
let encode (type a) (codec : a Jsont.t) (value : a) : Jsont.json =
22
+
match Jsont.Json.encode codec value with
23
+
| Ok json -> json
24
+
| Error e -> invalid_arg e
25
+
26
+
let encode_string (type a) (codec : a Jsont.t) (value : a) : string =
27
+
match Jsont_bytesrw.encode_string codec value with
28
+
| Ok s -> s
29
+
| Error e -> invalid_arg e
30
+
31
+
let pp_as_json (type a) (codec : a Jsont.t) ppf (value : a) =
32
+
json_printer ppf (encode codec value)
33
+
34
+
(* Automatic printer installation *)
35
+
36
+
let printers =
37
+
[ "Jmap.Id.pp";
38
+
"Jmap.Keyword.pp";
39
+
"Jmap.Role.pp";
40
+
"Jmap.Capability.pp";
41
+
"Jmap.Error.pp";
42
+
"Jmap_top.json_printer";
43
+
"Jmap_top.jsont_error_printer" ]
44
+
45
+
(* Suppress stderr during printer installation to avoid noise in MDX tests *)
46
+
let null_formatter = Format.make_formatter (fun _ _ _ -> ()) (fun () -> ())
47
+
48
+
let eval_string_quiet str =
49
+
try
50
+
let lexbuf = Lexing.from_string str in
51
+
let phrase = !Toploop.parse_toplevel_phrase lexbuf in
52
+
Toploop.execute_phrase false null_formatter phrase
53
+
with _ -> false
54
+
55
+
let rec do_install_printers = function
56
+
| [] -> true
57
+
| printer :: rest ->
58
+
let cmd = Printf.sprintf "#install_printer %s;;" printer in
59
+
eval_string_quiet cmd && do_install_printers rest
60
+
61
+
let install () =
62
+
(* Silently ignore failures - this handles non-toplevel contexts like MDX *)
63
+
ignore (do_install_printers printers)
64
+
65
+
(* Only auto-install when OCAML_TOPLEVEL_NAME is set, indicating a real toplevel *)
66
+
let () =
67
+
if Sys.getenv_opt "OCAML_TOPLEVEL_NAME" <> None then
68
+
install ()
+50
lib/top/jmap_top.mli
+50
lib/top/jmap_top.mli
···
1
+
(** Toplevel printers for JMAP types.
2
+
3
+
Printers are automatically installed when the library is loaded:
4
+
{[
5
+
#require "jmap.top";;
6
+
]}
7
+
8
+
After loading, JMAP types will display nicely:
9
+
{[
10
+
# Jmap.Id.of_string_exn "abc123";;
11
+
- : Jmap.Id.t = <id:abc123>
12
+
13
+
# Jmap.Keyword.of_string "$seen";;
14
+
- : Jmap.Keyword.t = `Seen
15
+
16
+
# Jmap.Role.of_string "inbox";;
17
+
- : Jmap.Role.t = `Inbox
18
+
]}
19
+
20
+
JSON values display as formatted strings, making it easy to see
21
+
how OCaml types map to JMAP JSON. *)
22
+
23
+
(** {1 JSON Printers} *)
24
+
25
+
val json_printer : Format.formatter -> Jsont.json -> unit
26
+
(** Formats a JSON value as a compact JSON string. *)
27
+
28
+
val jsont_error_printer : Format.formatter -> Jsont.Error.t -> unit
29
+
(** Formats a Jsont parsing error. *)
30
+
31
+
(** {1 JSON Encoding Helpers}
32
+
33
+
These functions encode OCaml types to JSON, useful for understanding
34
+
how the library maps to JMAP wire format. *)
35
+
36
+
val encode : 'a Jsont.t -> 'a -> Jsont.json
37
+
(** [encode codec value] encodes a value to JSON using the given codec.
38
+
Raises [Invalid_argument] on encoding failure. *)
39
+
40
+
val encode_string : 'a Jsont.t -> 'a -> string
41
+
(** [encode_string codec value] encodes a value to a JSON string. *)
42
+
43
+
val pp_as_json : 'a Jsont.t -> Format.formatter -> 'a -> unit
44
+
(** [pp_as_json codec ppf value] pretty-prints a value as JSON. *)
45
+
46
+
(** {1 Installation} *)
47
+
48
+
val install : unit -> unit
49
+
(** [install ()] installs all printers. This is called automatically when
50
+
the library is loaded, but can be called again if needed. *)
+28
-22
test/proto/test_proto.ml
+28
-22
test/proto/test_proto.ml
···
48
48
Alcotest.failf "%s: re-decode failed: %s" name (Jsont.Error.to_string e)
49
49
| Ok _ -> ()
50
50
51
+
(* Helpers for extracting values from optional fields in tests *)
52
+
let get_id opt = match opt with Some id -> Jmap.Proto.Id.to_string id | None -> Alcotest.fail "expected id"
53
+
let get_string opt = match opt with Some s -> s | None -> Alcotest.fail "expected string"
54
+
let get_int64 opt = match opt with Some n -> n | None -> Alcotest.fail "expected int64"
55
+
let get_bool opt = match opt with Some b -> b | None -> Alcotest.fail "expected bool"
56
+
51
57
(* ID tests *)
52
58
module Id_tests = struct
53
59
open Jmap.Proto
···
512
518
match decode Error.method_error_jsont json with
513
519
| Error e -> Alcotest.failf "decode failed: %s" (Jsont.Error.to_string e)
514
520
| Ok err ->
515
-
Alcotest.(check method_error_type_testable) "type" Error.Unknown_method err.type_
521
+
Alcotest.(check method_error_type_testable) "type" `Unknown_method err.type_
516
522
517
523
(* Additional error type tests *)
518
524
let test_set_error_forbidden () =
···
561
567
match decode Error.set_error_jsont json with
562
568
| Error e -> Alcotest.failf "decode failed: %s" (Jsont.Error.to_string e)
563
569
| Ok err ->
564
-
Alcotest.(check set_error_type_testable) "type" Error.Invalid_properties err.Error.type_;
570
+
Alcotest.(check set_error_type_testable) "type" `Invalid_properties err.Error.type_;
565
571
match err.Error.properties with
566
572
| None -> Alcotest.fail "expected properties"
567
573
| Some props -> Alcotest.(check int) "properties count" 2 (List.length props)
···
607
613
match decode Mailbox.jsont json with
608
614
| Error e -> Alcotest.failf "decode failed: %s" (Jsont.Error.to_string e)
609
615
| Ok mb ->
610
-
Alcotest.(check string) "id" "mb1" (Jmap.Proto.Id.to_string (Mailbox.id mb));
611
-
Alcotest.(check string) "name" "Inbox" (Mailbox.name mb);
612
-
Alcotest.(check (option role_testable)) "role" (Some Mailbox.Inbox) (Mailbox.role mb);
613
-
Alcotest.(check int64) "totalEmails" 150L (Mailbox.total_emails mb);
614
-
Alcotest.(check int64) "unreadEmails" 5L (Mailbox.unread_emails mb)
616
+
Alcotest.(check string) "id" "mb1" (get_id (Mailbox.id mb));
617
+
Alcotest.(check string) "name" "Inbox" (get_string (Mailbox.name mb));
618
+
Alcotest.(check (option role_testable)) "role" (Some `Inbox) (Mailbox.role mb);
619
+
Alcotest.(check int64) "totalEmails" 150L (get_int64 (Mailbox.total_emails mb));
620
+
Alcotest.(check int64) "unreadEmails" 5L (get_int64 (Mailbox.unread_emails mb))
615
621
616
622
let test_roundtrip () =
617
623
test_roundtrip "simple roundtrip" Mailbox.jsont "mail/mailbox/valid/simple.json" ()
···
627
633
match decode Mailbox.jsont json with
628
634
| Error e -> Alcotest.failf "decode failed: %s" (Jsont.Error.to_string e)
629
635
| Ok mb ->
630
-
Alcotest.(check (option role_testable)) "role" (Some Mailbox.Archive) (Mailbox.role mb);
631
-
Alcotest.(check int64) "totalEmails" 1000L (Mailbox.total_emails mb)
636
+
Alcotest.(check (option role_testable)) "role" (Some `Archive) (Mailbox.role mb);
637
+
Alcotest.(check int64) "totalEmails" 1000L (get_int64 (Mailbox.total_emails mb))
632
638
633
639
let tests = [
634
640
"valid: simple", `Quick, test_simple;
···
659
665
match decode Email.jsont json with
660
666
| Error e -> Alcotest.failf "decode failed: %s" (Jsont.Error.to_string e)
661
667
| Ok email ->
662
-
Alcotest.(check string) "id" "e1" (Jmap.Proto.Id.to_string (Email.id email));
663
-
Alcotest.(check string) "blobId" "blob1" (Jmap.Proto.Id.to_string (Email.blob_id email));
664
-
Alcotest.(check int64) "size" 1024L (Email.size email)
668
+
Alcotest.(check string) "id" "e1" (get_id (Email.id email));
669
+
Alcotest.(check string) "blobId" "blob1" (get_id (Email.blob_id email));
670
+
Alcotest.(check int64) "size" 1024L (get_int64 (Email.size email))
665
671
666
672
let test_full_values () =
667
673
let json = read_file "mail/email/valid/full.json" in
···
669
675
| Error e -> Alcotest.failf "decode failed: %s" (Jsont.Error.to_string e)
670
676
| Ok email ->
671
677
Alcotest.(check (option string)) "subject" (Some "Re: Important meeting") (Email.subject email);
672
-
Alcotest.(check bool) "hasAttachment" true (Email.has_attachment email);
678
+
Alcotest.(check bool) "hasAttachment" true (get_bool (Email.has_attachment email));
673
679
(* Check from address *)
674
680
match Email.from email with
675
681
| None -> Alcotest.fail "expected from address"
···
702
708
match decode Email.jsont json with
703
709
| Error e -> Alcotest.failf "decode failed: %s" (Jsont.Error.to_string e)
704
710
| Ok email ->
705
-
let keywords = Email.keywords email in
711
+
let keywords = Option.value ~default:[] (Email.keywords email) in
706
712
Alcotest.(check int) "keywords count" 3 (List.length keywords);
707
713
Alcotest.(check bool) "$seen present" true (List.mem_assoc "$seen" keywords);
708
714
Alcotest.(check bool) "$flagged present" true (List.mem_assoc "$flagged" keywords)
···
712
718
match decode Email.jsont json with
713
719
| Error e -> Alcotest.failf "decode failed: %s" (Jsont.Error.to_string e)
714
720
| Ok email ->
715
-
let mailbox_ids = Email.mailbox_ids email in
721
+
let mailbox_ids = Option.value ~default:[] (Email.mailbox_ids email) in
716
722
Alcotest.(check int) "mailboxIds count" 3 (List.length mailbox_ids)
717
723
718
724
let tests = [
···
747
753
match decode Thread.jsont json with
748
754
| Error e -> Alcotest.failf "decode failed: %s" (Jsont.Error.to_string e)
749
755
| Ok thread ->
750
-
Alcotest.(check string) "id" "t2" (Jmap.Proto.Id.to_string (Thread.id thread));
751
-
Alcotest.(check int) "emailIds count" 5 (List.length (Thread.email_ids thread))
756
+
Alcotest.(check string) "id" "t2" (get_id (Thread.id thread));
757
+
Alcotest.(check int) "emailIds count" 5 (List.length (Option.value ~default:[] (Thread.email_ids thread)))
752
758
753
759
let tests = [
754
760
"valid: simple", `Quick, test_simple;
···
769
775
match decode Identity.jsont json with
770
776
| Error e -> Alcotest.failf "decode failed: %s" (Jsont.Error.to_string e)
771
777
| Ok ident ->
772
-
Alcotest.(check string) "name" "Work Identity" (Identity.name ident);
773
-
Alcotest.(check string) "email" "john.doe@company.com" (Identity.email ident);
774
-
Alcotest.(check bool) "mayDelete" true (Identity.may_delete ident)
778
+
Alcotest.(check string) "name" "Work Identity" (get_string (Identity.name ident));
779
+
Alcotest.(check string) "email" "john.doe@company.com" (get_string (Identity.email ident));
780
+
Alcotest.(check bool) "mayDelete" true (get_bool (Identity.may_delete ident))
775
781
776
782
let tests = [
777
783
"valid: simple", `Quick, test_simple;
···
948
954
match decode Submission.jsont json with
949
955
| Error e -> Alcotest.failf "decode failed: %s" (Jsont.Error.to_string e)
950
956
| Ok sub ->
951
-
Alcotest.(check string) "id" "sub1" (Jmap.Proto.Id.to_string (Submission.id sub));
957
+
Alcotest.(check string) "id" "sub1" (get_id (Submission.id sub));
952
958
(* Check undoStatus is Pending *)
953
959
match Submission.undo_status sub with
954
-
| Submission.Pending -> ()
960
+
| Some `Pending -> ()
955
961
| _ -> Alcotest.fail "expected undoStatus to be pending"
956
962
957
963
let tests = [
+562
web/brr.html
+562
web/brr.html
···
1
+
<!DOCTYPE html>
2
+
<html lang="en">
3
+
<head>
4
+
<meta charset="utf-8">
5
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+
<title>JMAP Email Client</title>
7
+
<style>
8
+
:root {
9
+
--bg-color: #1a1a2e;
10
+
--card-bg: #16213e;
11
+
--accent: #0f3460;
12
+
--highlight: #e94560;
13
+
--text: #eee;
14
+
--text-muted: #888;
15
+
--success: #4ade80;
16
+
--error: #f87171;
17
+
--warning: #fbbf24;
18
+
}
19
+
20
+
* {
21
+
box-sizing: border-box;
22
+
margin: 0;
23
+
padding: 0;
24
+
}
25
+
26
+
body {
27
+
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
28
+
background: var(--bg-color);
29
+
color: var(--text);
30
+
min-height: 100vh;
31
+
padding: 20px;
32
+
}
33
+
34
+
.container {
35
+
max-width: 1200px;
36
+
margin: 0 auto;
37
+
}
38
+
39
+
header {
40
+
text-align: center;
41
+
margin-bottom: 30px;
42
+
}
43
+
44
+
h1 {
45
+
font-size: 2rem;
46
+
margin-bottom: 10px;
47
+
}
48
+
49
+
h1 span {
50
+
color: var(--highlight);
51
+
}
52
+
53
+
.subtitle {
54
+
color: var(--text-muted);
55
+
font-size: 0.9rem;
56
+
}
57
+
58
+
/* Top Section - Two Column Layout */
59
+
.top-section {
60
+
display: grid;
61
+
grid-template-columns: 1fr 1fr;
62
+
gap: 20px;
63
+
margin-bottom: 20px;
64
+
}
65
+
66
+
@media (max-width: 800px) {
67
+
.top-section {
68
+
grid-template-columns: 1fr;
69
+
}
70
+
}
71
+
72
+
/* Login Form */
73
+
.login-card {
74
+
background: var(--card-bg);
75
+
border-radius: 12px;
76
+
padding: 16px;
77
+
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
78
+
}
79
+
80
+
.login-card h3 {
81
+
margin-bottom: 12px;
82
+
font-size: 0.9rem;
83
+
color: var(--text-muted);
84
+
text-transform: uppercase;
85
+
letter-spacing: 1px;
86
+
}
87
+
88
+
.form-row {
89
+
display: flex;
90
+
gap: 12px;
91
+
margin-bottom: 12px;
92
+
}
93
+
94
+
.form-group {
95
+
flex: 1;
96
+
margin-bottom: 12px;
97
+
}
98
+
99
+
.form-group:last-child {
100
+
margin-bottom: 0;
101
+
}
102
+
103
+
.form-group.small {
104
+
flex: 0.4;
105
+
}
106
+
107
+
label {
108
+
display: block;
109
+
margin-bottom: 4px;
110
+
font-weight: 500;
111
+
font-size: 0.75rem;
112
+
color: var(--text-muted);
113
+
}
114
+
115
+
input[type="text"],
116
+
input[type="password"] {
117
+
width: 100%;
118
+
padding: 8px 12px;
119
+
border: 2px solid var(--accent);
120
+
border-radius: 6px;
121
+
background: var(--bg-color);
122
+
color: var(--text);
123
+
font-size: 0.85rem;
124
+
transition: border-color 0.2s;
125
+
}
126
+
127
+
input:focus {
128
+
outline: none;
129
+
border-color: var(--highlight);
130
+
}
131
+
132
+
.btn-row {
133
+
display: flex;
134
+
gap: 8px;
135
+
}
136
+
137
+
.btn {
138
+
flex: 1;
139
+
padding: 10px;
140
+
border: none;
141
+
border-radius: 6px;
142
+
font-size: 0.85rem;
143
+
font-weight: 600;
144
+
cursor: pointer;
145
+
transition: transform 0.1s, opacity 0.2s;
146
+
}
147
+
148
+
.btn:hover {
149
+
transform: translateY(-1px);
150
+
}
151
+
152
+
.btn:active {
153
+
transform: translateY(0);
154
+
}
155
+
156
+
.btn:disabled {
157
+
opacity: 0.5;
158
+
cursor: not-allowed;
159
+
transform: none;
160
+
}
161
+
162
+
.btn-primary {
163
+
background: var(--highlight);
164
+
color: white;
165
+
}
166
+
167
+
.btn-secondary {
168
+
background: var(--accent);
169
+
color: var(--text);
170
+
}
171
+
172
+
/* Status/Log Panel */
173
+
.log-panel {
174
+
background: var(--card-bg);
175
+
border-radius: 12px;
176
+
padding: 20px;
177
+
margin-bottom: 30px;
178
+
max-height: 500px;
179
+
overflow-y: auto;
180
+
}
181
+
182
+
.log-panel h3 {
183
+
margin-bottom: 15px;
184
+
font-size: 0.9rem;
185
+
color: var(--text-muted);
186
+
text-transform: uppercase;
187
+
letter-spacing: 1px;
188
+
}
189
+
190
+
.log-entry {
191
+
font-family: 'SF Mono', Monaco, 'Courier New', monospace;
192
+
font-size: 0.85rem;
193
+
padding: 8px 0;
194
+
border-bottom: 1px solid var(--accent);
195
+
}
196
+
197
+
.log-entry:last-child {
198
+
border-bottom: none;
199
+
}
200
+
201
+
.log-entry-header {
202
+
display: flex;
203
+
align-items: center;
204
+
gap: 8px;
205
+
}
206
+
207
+
.log-info .log-entry-header { color: var(--text); }
208
+
.log-success .log-entry-header { color: var(--success); }
209
+
.log-error .log-entry-header { color: var(--error); }
210
+
.log-warning .log-entry-header { color: var(--warning); }
211
+
212
+
.log-time {
213
+
color: var(--text-muted);
214
+
font-size: 0.8rem;
215
+
flex-shrink: 0;
216
+
}
217
+
218
+
.log-message {
219
+
flex: 1;
220
+
}
221
+
222
+
.log-expand-btn {
223
+
background: var(--accent);
224
+
border: none;
225
+
color: var(--text-muted);
226
+
padding: 2px 8px;
227
+
border-radius: 4px;
228
+
font-size: 0.7rem;
229
+
cursor: pointer;
230
+
font-family: inherit;
231
+
transition: background 0.2s, color 0.2s;
232
+
flex-shrink: 0;
233
+
}
234
+
235
+
.log-expand-btn:hover {
236
+
background: var(--highlight);
237
+
color: white;
238
+
}
239
+
240
+
.log-expand-btn.expanded {
241
+
background: var(--highlight);
242
+
color: white;
243
+
}
244
+
245
+
/* JSON content within log entry */
246
+
.log-json {
247
+
display: none;
248
+
margin-top: 8px;
249
+
border-radius: 8px;
250
+
overflow: hidden;
251
+
}
252
+
253
+
.log-json.visible {
254
+
display: block;
255
+
}
256
+
257
+
.log-json-header {
258
+
padding: 6px 12px;
259
+
font-size: 0.75rem;
260
+
font-weight: 600;
261
+
display: flex;
262
+
justify-content: space-between;
263
+
align-items: center;
264
+
}
265
+
266
+
.log-json.request .log-json-header {
267
+
background: var(--accent);
268
+
color: var(--highlight);
269
+
}
270
+
271
+
.log-json.response .log-json-header {
272
+
background: #1a3a2e;
273
+
color: var(--success);
274
+
}
275
+
276
+
.log-json-body {
277
+
background: var(--bg-color);
278
+
padding: 12px;
279
+
font-size: 0.75rem;
280
+
line-height: 1.4;
281
+
white-space: pre-wrap;
282
+
word-break: break-all;
283
+
max-height: 300px;
284
+
overflow-y: auto;
285
+
color: var(--text-muted);
286
+
}
287
+
288
+
.log-json-body.collapsed {
289
+
max-height: 100px;
290
+
}
291
+
292
+
.json-toggle-size {
293
+
background: none;
294
+
border: none;
295
+
color: inherit;
296
+
cursor: pointer;
297
+
font-size: 0.7rem;
298
+
opacity: 0.7;
299
+
}
300
+
301
+
.json-toggle-size:hover {
302
+
opacity: 1;
303
+
}
304
+
305
+
/* Session Info */
306
+
.session-info {
307
+
background: var(--card-bg);
308
+
border-radius: 12px;
309
+
padding: 16px;
310
+
display: none;
311
+
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
312
+
}
313
+
314
+
.session-info.visible {
315
+
display: block;
316
+
}
317
+
318
+
.session-info h3 {
319
+
margin-bottom: 12px;
320
+
font-size: 0.9rem;
321
+
color: var(--success);
322
+
text-transform: uppercase;
323
+
letter-spacing: 1px;
324
+
}
325
+
326
+
.session-detail {
327
+
display: flex;
328
+
margin-bottom: 6px;
329
+
font-size: 0.85rem;
330
+
}
331
+
332
+
.session-detail .label {
333
+
width: 100px;
334
+
color: var(--text-muted);
335
+
flex-shrink: 0;
336
+
}
337
+
338
+
.session-detail .value {
339
+
color: var(--text);
340
+
word-break: break-all;
341
+
font-family: 'SF Mono', Monaco, 'Courier New', monospace;
342
+
font-size: 0.8rem;
343
+
}
344
+
345
+
.search-box {
346
+
margin-top: 12px;
347
+
padding-top: 12px;
348
+
border-top: 1px solid var(--accent);
349
+
display: flex;
350
+
gap: 8px;
351
+
}
352
+
353
+
.search-box input {
354
+
flex: 1;
355
+
padding: 8px 12px;
356
+
border: 2px solid var(--accent);
357
+
border-radius: 6px;
358
+
background: var(--bg-color);
359
+
color: var(--text);
360
+
font-size: 0.85rem;
361
+
}
362
+
363
+
.search-box input:focus {
364
+
outline: none;
365
+
border-color: var(--highlight);
366
+
}
367
+
368
+
.btn-small {
369
+
flex: 0;
370
+
padding: 8px 16px;
371
+
white-space: nowrap;
372
+
}
373
+
374
+
/* Email List */
375
+
.email-list {
376
+
display: none;
377
+
}
378
+
379
+
.email-list.visible {
380
+
display: block;
381
+
}
382
+
383
+
.email-list h2 {
384
+
margin-bottom: 20px;
385
+
}
386
+
387
+
.email-item {
388
+
background: var(--card-bg);
389
+
border-radius: 8px;
390
+
padding: 16px 20px;
391
+
margin-bottom: 12px;
392
+
cursor: pointer;
393
+
transition: background 0.2s, transform 0.1s;
394
+
border-left: 4px solid transparent;
395
+
}
396
+
397
+
.email-item:hover {
398
+
background: var(--accent);
399
+
transform: translateX(4px);
400
+
}
401
+
402
+
.email-item.unread {
403
+
border-left-color: var(--highlight);
404
+
}
405
+
406
+
.email-header {
407
+
display: flex;
408
+
justify-content: space-between;
409
+
align-items: flex-start;
410
+
margin-bottom: 8px;
411
+
}
412
+
413
+
.email-from {
414
+
font-weight: 600;
415
+
font-size: 1rem;
416
+
}
417
+
418
+
.email-date {
419
+
color: var(--text-muted);
420
+
font-size: 0.85rem;
421
+
}
422
+
423
+
.email-subject {
424
+
font-size: 0.95rem;
425
+
color: var(--text);
426
+
margin-bottom: 6px;
427
+
}
428
+
429
+
.email-preview {
430
+
color: var(--text-muted);
431
+
font-size: 0.85rem;
432
+
white-space: nowrap;
433
+
overflow: hidden;
434
+
text-overflow: ellipsis;
435
+
}
436
+
437
+
.email-keywords {
438
+
margin-top: 8px;
439
+
}
440
+
441
+
.keyword-tag {
442
+
display: inline-block;
443
+
background: var(--accent);
444
+
color: var(--text-muted);
445
+
padding: 2px 8px;
446
+
border-radius: 4px;
447
+
font-size: 0.75rem;
448
+
margin-right: 6px;
449
+
}
450
+
451
+
.keyword-tag.flagged {
452
+
background: var(--warning);
453
+
color: var(--bg-color);
454
+
}
455
+
456
+
/* Loading spinner */
457
+
.spinner {
458
+
display: inline-block;
459
+
width: 20px;
460
+
height: 20px;
461
+
border: 2px solid var(--text-muted);
462
+
border-top-color: var(--highlight);
463
+
border-radius: 50%;
464
+
animation: spin 0.8s linear infinite;
465
+
margin-right: 10px;
466
+
vertical-align: middle;
467
+
}
468
+
469
+
@keyframes spin {
470
+
to { transform: rotate(360deg); }
471
+
}
472
+
473
+
/* Responsive */
474
+
@media (max-width: 600px) {
475
+
body {
476
+
padding: 10px;
477
+
}
478
+
479
+
.login-card {
480
+
padding: 20px;
481
+
}
482
+
483
+
h1 {
484
+
font-size: 1.5rem;
485
+
}
486
+
}
487
+
</style>
488
+
</head>
489
+
<body>
490
+
<div class="container">
491
+
<header>
492
+
<h1>JMAP <span>Email Client</span></h1>
493
+
<p class="subtitle">Built with OCaml and Brr</p>
494
+
</header>
495
+
496
+
<!-- Top Section: Login + Session Info -->
497
+
<div class="top-section">
498
+
<!-- Login Form -->
499
+
<div class="login-card" id="login-card">
500
+
<h3>Connection</h3>
501
+
<div class="form-group">
502
+
<label for="session-url">Session URL</label>
503
+
<input type="text" id="session-url"
504
+
value="https://api.fastmail.com/jmap/session"
505
+
placeholder="https://api.fastmail.com/jmap/session">
506
+
</div>
507
+
<div class="form-row">
508
+
<div class="form-group">
509
+
<label for="api-token">API Token</label>
510
+
<input type="password" id="api-token"
511
+
placeholder="Enter your JMAP API token">
512
+
</div>
513
+
</div>
514
+
<div class="btn-row">
515
+
<button class="btn btn-primary" id="connect-btn">Connect</button>
516
+
<button class="btn btn-secondary" id="disconnect-btn" style="display: none;">Disconnect</button>
517
+
</div>
518
+
</div>
519
+
520
+
<!-- Session Info -->
521
+
<div class="session-info" id="session-info">
522
+
<h3>Connected</h3>
523
+
<div class="session-detail">
524
+
<span class="label">Username:</span>
525
+
<span class="value" id="session-username">-</span>
526
+
</div>
527
+
<div class="session-detail">
528
+
<span class="label">API URL:</span>
529
+
<span class="value" id="session-api-url">-</span>
530
+
</div>
531
+
<div class="session-detail">
532
+
<span class="label">Account ID:</span>
533
+
<span class="value" id="session-account-id">-</span>
534
+
</div>
535
+
<div class="search-box">
536
+
<input type="text" id="email-search" placeholder="Search emails...">
537
+
<button class="btn btn-primary btn-small" id="search-btn">Search</button>
538
+
</div>
539
+
</div>
540
+
</div>
541
+
542
+
<!-- Log Panel with expandable JSON -->
543
+
<div class="log-panel" id="log-panel">
544
+
<h3>Activity Log</h3>
545
+
<div id="log-entries"></div>
546
+
</div>
547
+
548
+
<!-- Email List -->
549
+
<div class="email-list" id="email-list">
550
+
<h2>Recent Emails</h2>
551
+
<div id="emails"></div>
552
+
</div>
553
+
</div>
554
+
555
+
<script type="text/javascript" defer src="brr.js"></script>
556
+
<noscript>
557
+
<p style="text-align: center; padding: 50px; color: #888;">
558
+
Please enable JavaScript to use this application.
559
+
</p>
560
+
</noscript>
561
+
</body>
562
+
</html>
+539
web/brr_app.ml
+539
web/brr_app.ml
···
1
+
(*---------------------------------------------------------------------------
2
+
JMAP Email Client - Browser Application
3
+
Built with OCaml, Brr, and jmap-brr
4
+
---------------------------------------------------------------------------*)
5
+
6
+
open Brr
7
+
open Fut.Syntax
8
+
9
+
(* ---- Shared timestamp utilities ---- *)
10
+
11
+
let get_time_str () =
12
+
let date = Jv.new' (Jv.get Jv.global "Date") [||] in
13
+
let h = Jv.to_int (Jv.call date "getHours" [||]) in
14
+
let m = Jv.to_int (Jv.call date "getMinutes" [||]) in
15
+
let s = Jv.to_int (Jv.call date "getSeconds" [||]) in
16
+
Printf.sprintf "%02d:%02d:%02d" h m s
17
+
18
+
(* ---- JSON Masking ---- *)
19
+
20
+
module JsonMask = struct
21
+
let sensitive_keys = [
22
+
"accountId"; "blobId"; "threadId"; "emailId"; "id";
23
+
"username"; "apiUrl"; "downloadUrl"; "uploadUrl"; "eventSourceUrl";
24
+
"state"; "oldState"; "newState"
25
+
]
26
+
27
+
let is_sensitive key =
28
+
List.exists (fun k -> String.lowercase_ascii k = String.lowercase_ascii key) sensitive_keys
29
+
30
+
let mask_value s =
31
+
let len = String.length s in
32
+
if len <= 4 then String.make len '*'
33
+
else
34
+
let visible = min 4 (len / 4) in
35
+
(String.sub s 0 visible) ^ String.make (len - visible) '*'
36
+
37
+
let rec mask_json (json : Jv.t) : Jv.t =
38
+
if Jv.is_null json || Jv.is_undefined json then json
39
+
else if Jv.is_array json then
40
+
let arr = Jv.to_list Fun.id json in
41
+
let masked = List.map mask_json arr in
42
+
Jv.of_list Fun.id masked
43
+
else if Jstr.equal (Jv.typeof json) (Jstr.v "object") && not (Jv.is_array json) then
44
+
let obj = Jv.obj [||] in
45
+
let keys = Jv.call (Jv.get Jv.global "Object") "keys" [|json|] in
46
+
let key_list = Jv.to_list Jv.to_string keys in
47
+
List.iter (fun key ->
48
+
let value = Jv.get json key in
49
+
let masked_value =
50
+
if is_sensitive key && Jstr.equal (Jv.typeof value) (Jstr.v "string") then
51
+
Jv.of_string (mask_value (Jv.to_string value))
52
+
else
53
+
mask_json value
54
+
in
55
+
Jv.set obj key masked_value
56
+
) key_list;
57
+
obj
58
+
else
59
+
json
60
+
61
+
let format_json json =
62
+
let json_obj = Jv.get Jv.global "JSON" in
63
+
Jv.to_string (Jv.call json_obj "stringify" [|json; Jv.null; Jv.of_int 2|])
64
+
65
+
let mask_and_format json_str =
66
+
try
67
+
let json_obj = Jv.get Jv.global "JSON" in
68
+
let parsed = Jv.call json_obj "parse" [|Jv.of_string json_str|] in
69
+
let masked = mask_json parsed in
70
+
format_json masked
71
+
with _ -> json_str
72
+
end
73
+
74
+
(* ---- Logging with expandable JSON ---- *)
75
+
76
+
module Log = struct
77
+
type level = Info | Success | Error | Warning
78
+
79
+
let log_entries_el () =
80
+
Document.find_el_by_id G.document (Jstr.v "log-entries")
81
+
82
+
(* Reference to the last created entry for attaching JSON *)
83
+
let last_entry : El.t option ref = ref None
84
+
85
+
let add level msg =
86
+
match log_entries_el () with
87
+
| None -> Console.(log [str msg])
88
+
| Some container ->
89
+
let time_str = get_time_str () in
90
+
let class_name = match level with
91
+
| Info -> "log-info"
92
+
| Success -> "log-success"
93
+
| Error -> "log-error"
94
+
| Warning -> "log-warning"
95
+
in
96
+
let header = El.div ~at:At.[class' (Jstr.v "log-entry-header")] [
97
+
El.span ~at:At.[class' (Jstr.v "log-time")] [El.txt' time_str];
98
+
El.span ~at:At.[class' (Jstr.v "log-message")] [El.txt' msg];
99
+
] in
100
+
let entry = El.div ~at:At.[class' (Jstr.v ("log-entry " ^ class_name))] [header] in
101
+
last_entry := Some entry;
102
+
El.append_children container [entry];
103
+
(* Scroll to bottom *)
104
+
let scroll_height = Jv.get (El.to_jv container) "scrollHeight" in
105
+
Jv.set (El.to_jv container) "scrollTop" scroll_height
106
+
107
+
let attach_json direction label json_str =
108
+
match !last_entry with
109
+
| None -> ()
110
+
| Some entry ->
111
+
let formatted = JsonMask.mask_and_format json_str in
112
+
let class_name = match direction with
113
+
| `Request -> "log-json request"
114
+
| `Response -> "log-json response"
115
+
in
116
+
let arrow = match direction with
117
+
| `Request -> ">>> "
118
+
| `Response -> "<<< "
119
+
in
120
+
(* Create the JSON container (hidden by default) *)
121
+
let json_body = El.pre ~at:At.[class' (Jstr.v "log-json-body collapsed")] [El.txt' formatted] in
122
+
let expand_size_btn = El.button ~at:At.[class' (Jstr.v "json-toggle-size")] [El.txt' "[expand]"] in
123
+
let json_div = El.div ~at:At.[class' (Jstr.v class_name)] [
124
+
El.div ~at:At.[class' (Jstr.v "log-json-header")] [
125
+
El.span [El.txt' (arrow ^ label)];
126
+
expand_size_btn;
127
+
];
128
+
json_body;
129
+
] in
130
+
(* Add expand button to header if not already there *)
131
+
let header = El.children entry |> List.hd in
132
+
let existing_btns = El.children header |> List.filter (fun el ->
133
+
match El.at (Jstr.v "class") el with
134
+
| Some cls -> Option.is_some (Jstr.find_sub ~sub:(Jstr.v "log-expand-btn") cls)
135
+
| None -> false
136
+
) in
137
+
if List.length existing_btns = 0 then begin
138
+
let expand_btn = El.button ~at:At.[class' (Jstr.v "log-expand-btn")] [El.txt' "JSON"] in
139
+
El.append_children header [expand_btn];
140
+
(* Toggle visibility on click *)
141
+
ignore @@ Ev.listen Ev.click (fun _ev ->
142
+
let json_els = El.children entry |> List.filter (fun el ->
143
+
match El.at (Jstr.v "class") el with
144
+
| Some cls -> Option.is_some (Jstr.find_sub ~sub:(Jstr.v "log-json") cls)
145
+
| None -> false
146
+
) in
147
+
let is_visible = List.exists (fun el ->
148
+
El.class' (Jstr.v "visible") el
149
+
) json_els in
150
+
List.iter (fun el ->
151
+
El.set_class (Jstr.v "visible") (not is_visible) el
152
+
) json_els;
153
+
El.set_class (Jstr.v "expanded") (not is_visible) expand_btn
154
+
) (El.as_target expand_btn)
155
+
end;
156
+
(* Toggle body size *)
157
+
ignore @@ Ev.listen Ev.click (fun _ev ->
158
+
let is_collapsed = El.class' (Jstr.v "collapsed") json_body in
159
+
El.set_class (Jstr.v "collapsed") (not is_collapsed) json_body;
160
+
El.set_children expand_size_btn [El.txt' (if is_collapsed then "[collapse]" else "[expand]")]
161
+
) (El.as_target expand_size_btn);
162
+
El.append_children entry [json_div];
163
+
(* Scroll to bottom *)
164
+
match log_entries_el () with
165
+
| Some container ->
166
+
let scroll_height = Jv.get (El.to_jv container) "scrollHeight" in
167
+
Jv.set (El.to_jv container) "scrollTop" scroll_height
168
+
| None -> ()
169
+
170
+
let info msg = add Info msg
171
+
let success msg = add Success msg
172
+
let error msg = add Error msg
173
+
let warning msg = add Warning msg
174
+
end
175
+
176
+
(* ---- JSON Protocol Logging (bridges to Log.attach_json) ---- *)
177
+
178
+
module JsonLog = struct
179
+
let request label json = Log.attach_json `Request label json
180
+
let response label json = Log.attach_json `Response label json
181
+
let clear () = () (* No longer needed *)
182
+
end
183
+
184
+
(* ---- DOM Helpers ---- *)
185
+
186
+
let get_el id =
187
+
match Document.find_el_by_id G.document (Jstr.v id) with
188
+
| Some el -> el
189
+
| None -> failwith (Printf.sprintf "Element not found: %s" id)
190
+
191
+
let get_input_value id =
192
+
let el = get_el id in
193
+
Jstr.to_string (El.prop El.Prop.value el)
194
+
195
+
let set_text id text =
196
+
let el = get_el id in
197
+
El.set_children el [El.txt' text]
198
+
199
+
let show_el id =
200
+
let el = get_el id in
201
+
El.set_class (Jstr.v "visible") true el
202
+
203
+
let hide_el id =
204
+
let el = get_el id in
205
+
El.set_class (Jstr.v "visible") false el
206
+
207
+
let set_button_loading id loading =
208
+
let el = get_el id in
209
+
El.set_at At.Name.disabled (if loading then Some (Jstr.v "") else None) el;
210
+
if loading then
211
+
El.set_children el [
212
+
El.span ~at:At.[class' (Jstr.v "spinner")] [];
213
+
El.txt' "Connecting..."
214
+
]
215
+
else
216
+
El.set_children el [El.txt' "Connect"]
217
+
218
+
(* ---- Email Display ---- *)
219
+
220
+
let format_date ptime =
221
+
let date, time = Ptime.to_date_time ptime in
222
+
let y, m, d = date in
223
+
let (h, min, _), _ = time in
224
+
Printf.sprintf "%04d-%02d-%02d %02d:%02d" y m d h min
225
+
226
+
let format_address (addr : Jmap.Proto.Email_address.t) =
227
+
match addr.name with
228
+
| Some name -> Printf.sprintf "%s <%s>" name addr.email
229
+
| None -> addr.email
230
+
231
+
let format_addresses = function
232
+
| None -> "Unknown"
233
+
| Some [] -> "Unknown"
234
+
| Some (addr :: _) -> format_address addr
235
+
236
+
let render_email (email : Jmap.Proto.Email.t) =
237
+
let keywords = Option.value ~default:[] email.keywords in
238
+
let is_unread = not (List.exists (fun (k, v) -> k = "$seen" && v) keywords) in
239
+
let is_flagged = List.exists (fun (k, v) -> k = "$flagged" && v) keywords in
240
+
241
+
let from_str = format_addresses email.from in
242
+
let subject = Option.value ~default:"(No Subject)" email.subject in
243
+
let date_str = match email.received_at with Some t -> format_date t | None -> "?" in
244
+
let preview = Option.value ~default:"" email.preview in
245
+
246
+
let keyword_tags =
247
+
if is_flagged then
248
+
[El.span ~at:At.[class' (Jstr.v "keyword-tag flagged")] [El.txt' "Flagged"]]
249
+
else
250
+
[]
251
+
in
252
+
253
+
let classes = "email-item" ^ (if is_unread then " unread" else "") in
254
+
255
+
El.div ~at:At.[class' (Jstr.v classes)] [
256
+
El.div ~at:At.[class' (Jstr.v "email-header")] [
257
+
El.span ~at:At.[class' (Jstr.v "email-from")] [El.txt' from_str];
258
+
El.span ~at:At.[class' (Jstr.v "email-date")] [El.txt' date_str];
259
+
];
260
+
El.div ~at:At.[class' (Jstr.v "email-subject")] [El.txt' subject];
261
+
El.div ~at:At.[class' (Jstr.v "email-preview")] [El.txt' preview];
262
+
El.div ~at:At.[class' (Jstr.v "email-keywords")] keyword_tags;
263
+
]
264
+
265
+
let display_emails emails =
266
+
let container = get_el "emails" in
267
+
let email_els = List.map render_email emails in
268
+
El.set_children container email_els;
269
+
show_el "email-list"
270
+
271
+
(* ---- State ---- *)
272
+
273
+
type state = {
274
+
mutable connection : Jmap_brr.connection option;
275
+
mutable account_id : Jmap.Proto.Id.t option;
276
+
}
277
+
278
+
let state = { connection = None; account_id = None }
279
+
280
+
(* ---- JMAP Operations ---- *)
281
+
282
+
let fetch_emails ?(search_text="") conn account_id =
283
+
let search_msg = if search_text = "" then "Fetching recent emails..."
284
+
else Printf.sprintf "Searching emails for '%s'..." search_text in
285
+
Log.info search_msg;
286
+
287
+
let capabilities = [
288
+
Jmap.Capability.core_uri;
289
+
Jmap.Capability.mail_uri
290
+
] in
291
+
292
+
(* First, get mailboxes to find the inbox *)
293
+
let request, mailbox_handle =
294
+
let open Jmap.Chain in
295
+
build ~capabilities (mailbox_get ~account_id ())
296
+
in
297
+
298
+
let* response = Jmap_brr.request conn request in
299
+
match response with
300
+
| Error e ->
301
+
Log.error (Printf.sprintf "Failed to get mailboxes: %s"
302
+
(Jstr.to_string (Jv.Error.message e)));
303
+
Fut.return ()
304
+
| Ok resp ->
305
+
match Jmap.Chain.parse mailbox_handle resp with
306
+
| Error e ->
307
+
Log.error (Printf.sprintf "Failed to parse mailboxes: %s"
308
+
(Jsont.Error.to_string e));
309
+
Fut.return ()
310
+
| Ok mailbox_resp ->
311
+
let mailboxes = mailbox_resp.list in
312
+
Log.info (Printf.sprintf "Found %d mailboxes" (List.length mailboxes));
313
+
314
+
(* Find inbox or use first mailbox *)
315
+
let inbox_id =
316
+
match List.find_opt (fun m ->
317
+
match m.Jmap.Proto.Mailbox.role with
318
+
| Some `Inbox -> true
319
+
| _ -> false
320
+
) mailboxes with
321
+
| Some m -> m.Jmap.Proto.Mailbox.id
322
+
| None ->
323
+
match mailboxes with
324
+
| m :: _ -> m.Jmap.Proto.Mailbox.id
325
+
| [] ->
326
+
Log.error "No mailboxes found";
327
+
failwith "No mailboxes"
328
+
in
329
+
let inbox_id = match inbox_id with
330
+
| Some id -> id
331
+
| None ->
332
+
Log.error "Inbox has no ID";
333
+
failwith "Inbox has no ID"
334
+
in
335
+
336
+
let query_msg = if search_text = "" then "Querying emails from inbox..."
337
+
else Printf.sprintf "Querying inbox for '%s'..." search_text in
338
+
Log.info query_msg;
339
+
340
+
(* Query for recent emails with optional text search *)
341
+
let text_filter = if search_text = "" then None else Some search_text in
342
+
let filter_condition : Jmap.Proto.Email.Filter_condition.t = {
343
+
in_mailbox = Some inbox_id;
344
+
in_mailbox_other_than = None;
345
+
before = None;
346
+
after = None;
347
+
min_size = None;
348
+
max_size = None;
349
+
all_in_thread_have_keyword = None;
350
+
some_in_thread_have_keyword = None;
351
+
none_in_thread_have_keyword = None;
352
+
has_keyword = None;
353
+
not_keyword = None;
354
+
has_attachment = None;
355
+
text = text_filter;
356
+
from = None;
357
+
to_ = None;
358
+
cc = None;
359
+
bcc = None;
360
+
subject = None;
361
+
body = None;
362
+
header = None;
363
+
} in
364
+
365
+
let request2, email_handle =
366
+
let open Jmap.Chain in
367
+
build ~capabilities begin
368
+
let* query = email_query ~account_id
369
+
~filter:(Jmap.Proto.Filter.Condition filter_condition)
370
+
~sort:[Jmap.Proto.Filter.comparator ~is_ascending:false "receivedAt"]
371
+
~limit:20L
372
+
()
373
+
in
374
+
email_get ~account_id
375
+
~ids:(from_query query)
376
+
~properties:["id"; "blobId"; "threadId"; "mailboxIds"; "keywords";
377
+
"size"; "receivedAt"; "from"; "subject"; "preview";
378
+
"hasAttachment"]
379
+
()
380
+
end
381
+
in
382
+
383
+
Log.info "Sending email query request...";
384
+
let* response2 = Jmap_brr.request conn request2 in
385
+
Log.info "Got email query response";
386
+
match response2 with
387
+
| Error e ->
388
+
Log.error (Printf.sprintf "Failed to query emails: %s"
389
+
(Jstr.to_string (Jv.Error.message e)));
390
+
Fut.return ()
391
+
| Ok resp2 ->
392
+
Log.info "Parsing email response...";
393
+
match Jmap.Chain.parse email_handle resp2 with
394
+
| Error e ->
395
+
Log.error (Printf.sprintf "Failed to parse emails: %s"
396
+
(Jsont.Error.to_string e));
397
+
Fut.return ()
398
+
| Ok email_resp ->
399
+
let emails = email_resp.list in
400
+
Log.success (Printf.sprintf "Loaded %d emails" (List.length emails));
401
+
(try
402
+
display_emails emails
403
+
with exn ->
404
+
Log.error (Printf.sprintf "Display error: %s" (Printexc.to_string exn)));
405
+
Fut.return ()
406
+
407
+
(* ---- Connection ---- *)
408
+
409
+
let connect () =
410
+
let session_url = get_input_value "session-url" in
411
+
let api_token = get_input_value "api-token" in
412
+
413
+
if String.length api_token = 0 then begin
414
+
Log.error "Please enter an API token";
415
+
Fut.return ()
416
+
end else begin
417
+
Log.info (Printf.sprintf "Connecting to %s..." session_url);
418
+
set_button_loading "connect-btn" true;
419
+
420
+
let* result = Jmap_brr.get_session
421
+
~url:(Jstr.v session_url)
422
+
~token:(Jstr.v api_token)
423
+
in
424
+
425
+
set_button_loading "connect-btn" false;
426
+
427
+
match result with
428
+
| Error e ->
429
+
let msg = Jstr.to_string (Jv.Error.message e) in
430
+
Log.error (Printf.sprintf "Connection failed: %s" msg);
431
+
Fut.return ()
432
+
| Ok conn ->
433
+
let session = Jmap_brr.session conn in
434
+
let username = Jmap.Proto.Session.username session in
435
+
let api_url = Jmap.Proto.Session.api_url session in
436
+
437
+
Log.success (Printf.sprintf "Connected as %s" username);
438
+
439
+
(* Find primary mail account *)
440
+
let account_id =
441
+
match Jmap.Proto.Session.primary_account_for
442
+
Jmap.Capability.mail_uri session with
443
+
| Some id -> id
444
+
| None ->
445
+
match Jmap.Proto.Session.accounts session with
446
+
| (id, _) :: _ -> id
447
+
| [] -> failwith "No accounts found"
448
+
in
449
+
450
+
state.connection <- Some conn;
451
+
state.account_id <- Some account_id;
452
+
453
+
(* Update UI *)
454
+
set_text "session-username" username;
455
+
set_text "session-api-url" api_url;
456
+
set_text "session-account-id" (Jmap.Proto.Id.to_string account_id);
457
+
show_el "session-info";
458
+
459
+
(* Show disconnect button *)
460
+
let connect_btn = get_el "connect-btn" in
461
+
let disconnect_btn = get_el "disconnect-btn" in
462
+
El.set_inline_style (Jstr.v "display") (Jstr.v "none") connect_btn;
463
+
El.set_inline_style (Jstr.v "display") (Jstr.v "block") disconnect_btn;
464
+
465
+
(* Fetch emails *)
466
+
fetch_emails conn account_id
467
+
end
468
+
469
+
let disconnect () =
470
+
state.connection <- None;
471
+
state.account_id <- None;
472
+
473
+
hide_el "session-info";
474
+
hide_el "email-list";
475
+
476
+
(* Reset buttons *)
477
+
let connect_btn = get_el "connect-btn" in
478
+
let disconnect_btn = get_el "disconnect-btn" in
479
+
El.set_inline_style (Jstr.v "display") (Jstr.v "block") connect_btn;
480
+
El.set_inline_style (Jstr.v "display") (Jstr.v "none") disconnect_btn;
481
+
482
+
Log.info "Disconnected"
483
+
484
+
let search_emails () =
485
+
match state.connection, state.account_id with
486
+
| Some conn, Some account_id ->
487
+
let search_text = get_input_value "email-search" in
488
+
ignore (fetch_emails ~search_text conn account_id)
489
+
| _ ->
490
+
Log.warning "Not connected"
491
+
492
+
(* ---- Main ---- *)
493
+
494
+
let setup_handlers () =
495
+
let connect_btn = get_el "connect-btn" in
496
+
let disconnect_btn = get_el "disconnect-btn" in
497
+
498
+
(* Connect button click *)
499
+
ignore @@ Ev.listen Ev.click (fun _ev ->
500
+
ignore (connect ())
501
+
) (El.as_target connect_btn);
502
+
503
+
(* Disconnect button click *)
504
+
ignore @@ Ev.listen Ev.click (fun _ev ->
505
+
disconnect ()
506
+
) (El.as_target disconnect_btn);
507
+
508
+
(* Enter key in token field *)
509
+
let token_input = get_el "api-token" in
510
+
ignore @@ Ev.listen Ev.keydown (fun ev ->
511
+
let kev = Ev.as_type ev in
512
+
if Jstr.equal (Ev.Keyboard.key kev) (Jstr.v "Enter") then
513
+
ignore (connect ())
514
+
) (El.as_target token_input);
515
+
516
+
(* Search button click *)
517
+
let search_btn = get_el "search-btn" in
518
+
ignore @@ Ev.listen Ev.click (fun _ev ->
519
+
search_emails ()
520
+
) (El.as_target search_btn);
521
+
522
+
(* Enter key in search field *)
523
+
let search_input = get_el "email-search" in
524
+
ignore @@ Ev.listen Ev.keydown (fun ev ->
525
+
let kev = Ev.as_type ev in
526
+
if Jstr.equal (Ev.Keyboard.key kev) (Jstr.v "Enter") then
527
+
search_emails ()
528
+
) (El.as_target search_input)
529
+
530
+
let main () =
531
+
(* Register JSON loggers *)
532
+
Jmap_brr.set_request_logger JsonLog.request;
533
+
Jmap_brr.set_response_logger JsonLog.response;
534
+
535
+
Log.info "JMAP Email Client initialized";
536
+
Log.info "Enter your JMAP server URL and API token to connect";
537
+
setup_handlers ()
538
+
539
+
let () = main ()
+15
web/dune
+15
web/dune