this repo has no description

more cli

+3 -2
bin/dune
··· 1 1 (executable 2 - (name jmap_test) 3 - (public_name jmap-test) 2 + (name jmap) 3 + (public_name jmap) 4 4 (package jmap-eio) 5 + (modules jmap) 5 6 (libraries jmap-eio eio_main))
-114
bin/flag_color_test.ml
··· 1 - (** Demo of message flags and mailbox attributes functionality *) 2 - 3 - open Jmap_mail.Types 4 - 5 - (** Demonstrate flag color functionality *) 6 - let demo_flag_colors () = 7 - Printf.printf "Flag Color Demo:\n"; 8 - Printf.printf "================\n"; 9 - 10 - (* Show all flag colors and their bit patterns *) 11 - let colors = [Red; Orange; Yellow; Green; Blue; Purple; Gray] in 12 - List.iter (fun color -> 13 - let (bit0, bit1, bit2) = bits_of_flag_color color in 14 - Printf.printf "Color: %-7s Bits: %d%d%d\n" 15 - (match color with 16 - | Red -> "Red" 17 - | Orange -> "Orange" 18 - | Yellow -> "Yellow" 19 - | Green -> "Green" 20 - | Blue -> "Blue" 21 - | Purple -> "Purple" 22 - | Gray -> "Gray") 23 - (if bit0 then 1 else 0) 24 - (if bit1 then 1 else 0) 25 - (if bit2 then 1 else 0) 26 - ) colors; 27 - 28 - Printf.printf "\n" 29 - 30 - (** Demonstrate message keyword functionality *) 31 - let demo_message_keywords () = 32 - Printf.printf "Message Keywords Demo:\n"; 33 - Printf.printf "=====================\n"; 34 - 35 - (* Show all standard message keywords and their string representations *) 36 - let keywords = [ 37 - Notify; Muted; Followed; Memo; HasMemo; HasAttachment; HasNoAttachment; 38 - AutoSent; Unsubscribed; CanUnsubscribe; Imported; IsTrusted; 39 - MaskedEmail; New; MailFlagBit0; MailFlagBit1; MailFlagBit2 40 - ] in 41 - 42 - List.iter (fun kw -> 43 - Printf.printf "%-15s -> %s\n" 44 - (match kw with 45 - | Notify -> "Notify" 46 - | Muted -> "Muted" 47 - | Followed -> "Followed" 48 - | Memo -> "Memo" 49 - | HasMemo -> "HasMemo" 50 - | HasAttachment -> "HasAttachment" 51 - | HasNoAttachment -> "HasNoAttachment" 52 - | AutoSent -> "AutoSent" 53 - | Unsubscribed -> "Unsubscribed" 54 - | CanUnsubscribe -> "CanUnsubscribe" 55 - | Imported -> "Imported" 56 - | IsTrusted -> "IsTrusted" 57 - | MaskedEmail -> "MaskedEmail" 58 - | New -> "New" 59 - | MailFlagBit0 -> "MailFlagBit0" 60 - | MailFlagBit1 -> "MailFlagBit1" 61 - | MailFlagBit2 -> "MailFlagBit2" 62 - | OtherKeyword s -> "Other: " ^ s) 63 - (string_of_message_keyword kw) 64 - ) keywords; 65 - 66 - Printf.printf "\n" 67 - 68 - (** Demonstrate mailbox attribute functionality *) 69 - let demo_mailbox_attributes () = 70 - Printf.printf "Mailbox Attributes Demo:\n"; 71 - Printf.printf "=======================\n"; 72 - 73 - (* Show all standard mailbox attributes and their string representations *) 74 - let attributes = [Snoozed; Scheduled; Memos] in 75 - 76 - List.iter (fun attr -> 77 - Printf.printf "%-10s -> %s\n" 78 - (match attr with 79 - | Snoozed -> "Snoozed" 80 - | Scheduled -> "Scheduled" 81 - | Memos -> "Memos" 82 - | OtherAttribute s -> "Other: " ^ s) 83 - (string_of_mailbox_attribute attr) 84 - ) attributes; 85 - 86 - Printf.printf "\n" 87 - 88 - (** Demonstrate formatting functionality *) 89 - let demo_formatting () = 90 - Printf.printf "Keyword Formatting Demo:\n"; 91 - Printf.printf "======================\n"; 92 - 93 - (* Create a sample email with various keywords *) 94 - let sample_keywords = [ 95 - (Flagged, true); (* Standard flag *) 96 - (Custom "$MailFlagBit0", true); (* Flag color bit *) 97 - (Custom "$MailFlagBit2", true); (* Flag color bit *) 98 - (Custom "$notify", true); (* Message keyword *) 99 - (Custom "$followed", true); (* Message keyword *) 100 - (Custom "$hasattachment", true); (* Message keyword *) 101 - (Seen, false); (* Inactive keyword *) 102 - (Custom "$random", true); (* Unknown keyword *) 103 - ] in 104 - 105 - (* Test formatted output *) 106 - let formatted = format_email_keywords sample_keywords in 107 - Printf.printf "Formatted keywords: %s\n\n" formatted 108 - 109 - (** Main entry point *) 110 - let () = 111 - demo_flag_colors (); 112 - demo_message_keywords (); 113 - demo_mailbox_attributes (); 114 - demo_formatting ()
+749
bin/jmap.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** JMAP command-line client *) 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 + let format_email_address (addr : Jmap_mail.Email_address.t) = 21 + match addr.name with 22 + | Some name -> Printf.sprintf "%s <%s>" name addr.email 23 + | None -> addr.email 24 + 25 + let format_email_addresses addrs = 26 + String.concat ", " (List.map format_email_address addrs) 27 + 28 + let format_keywords keywords = 29 + keywords 30 + |> List.filter_map (fun (k, v) -> if v then Some k else None) 31 + |> String.concat " " 32 + 33 + (** {1 Session Command} *) 34 + 35 + let session_cmd = 36 + let run cfg = 37 + Eio_main.run @@ fun env -> 38 + Eio.Switch.run @@ fun sw -> 39 + let client = Jmap_eio.Cli.create_client ~sw env cfg in 40 + let session = Jmap_eio.Client.session client in 41 + 42 + Fmt.pr "@[<v>%a@," Fmt.(styled `Bold string) "Session Information:"; 43 + Fmt.pr " Username: %a@," Fmt.(styled `Green string) session.username; 44 + Fmt.pr " State: %s@," session.state; 45 + Fmt.pr " API URL: %s@," session.api_url; 46 + Fmt.pr " Upload URL: %s@," session.upload_url; 47 + Fmt.pr " Download URL: %s@," session.download_url; 48 + Fmt.pr "@, %a@," Fmt.(styled `Bold string) "Capabilities:"; 49 + List.iter (fun (cap, _) -> 50 + Fmt.pr " %s@," cap 51 + ) session.capabilities; 52 + Fmt.pr "@, %a@," Fmt.(styled `Bold string) "Accounts:"; 53 + List.iter (fun (id, acct) -> 54 + let acct : Jmap_proto.Session.Account.t = acct in 55 + Fmt.pr " %a: %s (personal=%b, read_only=%b)@," 56 + Fmt.(styled `Cyan string) (Jmap_proto.Id.to_string id) 57 + acct.name acct.is_personal acct.is_read_only 58 + ) session.accounts; 59 + Fmt.pr "@, %a@," Fmt.(styled `Bold string) "Primary Accounts:"; 60 + List.iter (fun (cap, id) -> 61 + Fmt.pr " %s: %s@," cap (Jmap_proto.Id.to_string id) 62 + ) session.primary_accounts; 63 + Fmt.pr "@]@." 64 + in 65 + let doc = "Show JMAP session information" in 66 + let info = Cmd.info "session" ~doc in 67 + Cmd.v info Term.(const run $ Jmap_eio.Cli.config_term) 68 + 69 + (** {1 Mailboxes Command} *) 70 + 71 + let mailboxes_cmd = 72 + let run cfg = 73 + Eio_main.run @@ fun env -> 74 + Eio.Switch.run @@ fun sw -> 75 + let client = Jmap_eio.Cli.create_client ~sw env cfg in 76 + let account_id = Jmap_eio.Cli.get_account_id cfg client in 77 + 78 + Jmap_eio.Cli.debug cfg "Fetching mailboxes for account %s" (Jmap_proto.Id.to_string account_id); 79 + 80 + let req = Jmap_eio.Client.Build.( 81 + make_request 82 + ~capabilities:[Jmap_proto.Capability.core; Jmap_proto.Capability.mail] 83 + [mailbox_get ~call_id:"m1" ~account_id ()] 84 + ) in 85 + 86 + match Jmap_eio.Client.request client req with 87 + | Error e -> 88 + Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e); 89 + exit 1 90 + | Ok response -> 91 + match Jmap_eio.Client.Parse.parse_mailbox_get ~call_id:"m1" response with 92 + | Error e -> 93 + Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e); 94 + exit 1 95 + | Ok result -> 96 + Fmt.pr "@[<v>%a (state: %s)@,@," 97 + Fmt.(styled `Bold string) "Mailboxes" 98 + result.state; 99 + (* Sort by sort_order then name *) 100 + let sorted = List.sort (fun (a : Jmap_mail.Mailbox.t) (b : Jmap_mail.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 103 + ) result.list in 104 + List.iter (fun (mbox : Jmap_mail.Mailbox.t) -> 105 + let role_str = match mbox.role with 106 + | Some role -> Printf.sprintf " [%s]" (Jmap_mail.Mailbox.role_to_string role) 107 + | None -> "" 108 + in 109 + Fmt.pr " %a %s%a (%Ld total, %Ld unread)@," 110 + Fmt.(styled `Cyan string) (Jmap_proto.Id.to_string mbox.id) 111 + mbox.name 112 + Fmt.(styled `Yellow string) role_str 113 + mbox.total_emails mbox.unread_emails 114 + ) sorted; 115 + Fmt.pr "@]@." 116 + in 117 + let doc = "List mailboxes" in 118 + let info = Cmd.info "mailboxes" ~doc in 119 + Cmd.v info Term.(const run $ Jmap_eio.Cli.config_term) 120 + 121 + (** {1 Emails Command} *) 122 + 123 + let emails_cmd = 124 + let limit_term = 125 + let doc = "Maximum number of emails to list" in 126 + Arg.(value & opt int 20 & info ["limit"; "n"] ~docv:"N" ~doc) 127 + in 128 + let mailbox_term = 129 + let doc = "Mailbox ID to filter by" in 130 + Arg.(value & opt (some string) None & info ["mailbox"; "m"] ~docv:"ID" ~doc) 131 + in 132 + let run cfg limit mailbox_id_str = 133 + Eio_main.run @@ fun env -> 134 + Eio.Switch.run @@ fun sw -> 135 + let client = Jmap_eio.Cli.create_client ~sw env cfg in 136 + let account_id = Jmap_eio.Cli.get_account_id cfg client in 137 + 138 + Jmap_eio.Cli.debug cfg "Querying emails with limit %d" limit; 139 + 140 + (* Build filter if mailbox specified *) 141 + let filter = match mailbox_id_str with 142 + | Some id_str -> 143 + let mailbox_id = Jmap_proto.Id.of_string_exn id_str in 144 + let cond : Jmap_mail.Email.Filter_condition.t = { 145 + in_mailbox = Some mailbox_id; 146 + in_mailbox_other_than = None; 147 + before = None; after = None; 148 + min_size = None; max_size = None; 149 + all_in_thread_have_keyword = None; 150 + some_in_thread_have_keyword = None; 151 + none_in_thread_have_keyword = None; 152 + has_keyword = None; not_keyword = None; 153 + has_attachment = None; 154 + text = None; from = None; to_ = None; 155 + cc = None; bcc = None; subject = None; 156 + body = None; header = None; 157 + } in 158 + Some (Jmap_proto.Filter.Condition cond) 159 + | None -> None 160 + in 161 + 162 + let sort = [Jmap_proto.Filter.comparator ~is_ascending:false "receivedAt"] in 163 + let query_inv = Jmap_eio.Client.Build.email_query 164 + ~call_id:"q1" 165 + ~account_id 166 + ?filter 167 + ~sort 168 + ~limit:(Int64.of_int limit) 169 + () 170 + in 171 + 172 + let req = Jmap_eio.Client.Build.( 173 + make_request 174 + ~capabilities:[Jmap_proto.Capability.core; Jmap_proto.Capability.mail] 175 + [query_inv] 176 + ) in 177 + 178 + match Jmap_eio.Client.request client req with 179 + | Error e -> 180 + Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e); 181 + exit 1 182 + | Ok response -> 183 + match Jmap_eio.Client.Parse.parse_email_query ~call_id:"q1" response with 184 + | Error e -> 185 + Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e); 186 + exit 1 187 + | Ok query_result -> 188 + let email_ids = query_result.ids in 189 + Jmap_eio.Cli.debug cfg "Found %d email IDs" (List.length email_ids); 190 + 191 + if List.length email_ids = 0 then ( 192 + Fmt.pr "No emails found.@."; 193 + ) else ( 194 + (* Fetch email details *) 195 + let get_inv = Jmap_eio.Client.Build.email_get 196 + ~call_id:"g1" 197 + ~account_id 198 + ~ids:email_ids 199 + ~properties:["id"; "blobId"; "threadId"; "mailboxIds"; "keywords"; 200 + "size"; "receivedAt"; "subject"; "from"; "preview"] 201 + () 202 + in 203 + let req2 = Jmap_eio.Client.Build.( 204 + make_request 205 + ~capabilities:[Jmap_proto.Capability.core; Jmap_proto.Capability.mail] 206 + [get_inv] 207 + ) in 208 + 209 + match Jmap_eio.Client.request client req2 with 210 + | Error e -> 211 + Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e); 212 + exit 1 213 + | Ok response2 -> 214 + match Jmap_eio.Client.Parse.parse_email_get ~call_id:"g1" response2 with 215 + | Error e -> 216 + Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e); 217 + exit 1 218 + | Ok get_result -> 219 + Fmt.pr "@[<v>%a (showing %d of %s)@,@," 220 + Fmt.(styled `Bold string) "Emails" 221 + (List.length get_result.list) 222 + (match query_result.total with 223 + | Some n -> Int64.to_string n 224 + | None -> "?"); 225 + List.iter (fun (email : Jmap_mail.Email.t) -> 226 + let from_str = match email.from with 227 + | Some addrs -> format_email_addresses addrs 228 + | None -> "(unknown)" 229 + in 230 + let subject = Option.value email.subject ~default:"(no subject)" in 231 + let flags = format_keywords email.keywords in 232 + let flag_str = if flags = "" then "" else " [" ^ flags ^ "]" in 233 + Fmt.pr " %a %s@," 234 + Fmt.(styled `Cyan string) (Jmap_proto.Id.to_string email.id) 235 + (ptime_to_string email.received_at); 236 + Fmt.pr " From: %s@," (truncate_string 60 from_str); 237 + Fmt.pr " Subject: %a%s@," 238 + Fmt.(styled `White string) (truncate_string 60 subject) 239 + flag_str; 240 + Fmt.pr " Preview: %s@,@," 241 + (truncate_string 70 email.preview); 242 + ) get_result.list; 243 + Fmt.pr "@]@." 244 + ) 245 + in 246 + let doc = "List emails" in 247 + let info = Cmd.info "emails" ~doc in 248 + Cmd.v info Term.(const run $ Jmap_eio.Cli.config_term $ limit_term $ mailbox_term) 249 + 250 + (** {1 Search Command} *) 251 + 252 + let search_cmd = 253 + let query_term = 254 + let doc = "Search query text" in 255 + Arg.(required & pos 0 (some string) None & info [] ~docv:"QUERY" ~doc) 256 + in 257 + let limit_term = 258 + let doc = "Maximum number of results" in 259 + Arg.(value & opt int 20 & info ["limit"; "n"] ~docv:"N" ~doc) 260 + in 261 + let run cfg query limit = 262 + Eio_main.run @@ fun env -> 263 + Eio.Switch.run @@ fun sw -> 264 + let client = Jmap_eio.Cli.create_client ~sw env cfg in 265 + let account_id = Jmap_eio.Cli.get_account_id cfg client in 266 + 267 + Jmap_eio.Cli.debug cfg "Searching for: %s" query; 268 + 269 + (* Build text filter *) 270 + let cond : Jmap_mail.Email.Filter_condition.t = { 271 + in_mailbox = None; in_mailbox_other_than = None; 272 + before = None; after = None; 273 + min_size = None; max_size = None; 274 + all_in_thread_have_keyword = None; 275 + some_in_thread_have_keyword = None; 276 + none_in_thread_have_keyword = None; 277 + has_keyword = None; not_keyword = None; 278 + has_attachment = None; 279 + text = Some query; 280 + from = None; to_ = None; 281 + cc = None; bcc = None; subject = None; 282 + body = None; header = None; 283 + } in 284 + let filter = Jmap_proto.Filter.Condition cond in 285 + 286 + let sort = [Jmap_proto.Filter.comparator ~is_ascending:false "receivedAt"] in 287 + let query_inv = Jmap_eio.Client.Build.email_query 288 + ~call_id:"q1" 289 + ~account_id 290 + ~filter 291 + ~sort 292 + ~limit:(Int64.of_int limit) 293 + () 294 + in 295 + 296 + let req = Jmap_eio.Client.Build.( 297 + make_request 298 + ~capabilities:[Jmap_proto.Capability.core; Jmap_proto.Capability.mail] 299 + [query_inv] 300 + ) in 301 + 302 + match Jmap_eio.Client.request client req with 303 + | Error e -> 304 + Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e); 305 + exit 1 306 + | Ok response -> 307 + match Jmap_eio.Client.Parse.parse_email_query ~call_id:"q1" response with 308 + | Error e -> 309 + Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e); 310 + exit 1 311 + | Ok query_result -> 312 + let email_ids = query_result.ids in 313 + 314 + if List.length email_ids = 0 then ( 315 + Fmt.pr "No emails found matching: %s@." query; 316 + ) else ( 317 + (* Fetch email details *) 318 + let get_inv = Jmap_eio.Client.Build.email_get 319 + ~call_id:"g1" 320 + ~account_id 321 + ~ids:email_ids 322 + ~properties:["id"; "blobId"; "threadId"; "mailboxIds"; "keywords"; 323 + "size"; "receivedAt"; "subject"; "from"; "preview"] 324 + () 325 + in 326 + let req2 = Jmap_eio.Client.Build.( 327 + make_request 328 + ~capabilities:[Jmap_proto.Capability.core; Jmap_proto.Capability.mail] 329 + [get_inv] 330 + ) in 331 + 332 + match Jmap_eio.Client.request client req2 with 333 + | Error e -> 334 + Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e); 335 + exit 1 336 + | Ok response2 -> 337 + match Jmap_eio.Client.Parse.parse_email_get ~call_id:"g1" response2 with 338 + | Error e -> 339 + Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e); 340 + exit 1 341 + | Ok get_result -> 342 + Fmt.pr "@[<v>%a for \"%s\" (%d results)@,@," 343 + Fmt.(styled `Bold string) "Search results" 344 + query 345 + (List.length get_result.list); 346 + List.iter (fun (email : Jmap_mail.Email.t) -> 347 + let from_str = match email.from with 348 + | Some addrs -> format_email_addresses addrs 349 + | None -> "(unknown)" 350 + in 351 + let subject = Option.value email.subject ~default:"(no subject)" in 352 + Fmt.pr " %a %s@," 353 + Fmt.(styled `Cyan string) (Jmap_proto.Id.to_string email.id) 354 + (ptime_to_string email.received_at); 355 + Fmt.pr " From: %s@," (truncate_string 60 from_str); 356 + Fmt.pr " Subject: %a@," 357 + Fmt.(styled `White string) (truncate_string 60 subject); 358 + Fmt.pr " Preview: %s@,@," 359 + (truncate_string 70 email.preview); 360 + ) get_result.list; 361 + Fmt.pr "@]@." 362 + ) 363 + in 364 + let doc = "Search emails by text" in 365 + let info = Cmd.info "search" ~doc in 366 + Cmd.v info Term.(const run $ Jmap_eio.Cli.config_term $ query_term $ limit_term) 367 + 368 + (** {1 Recent Command - chains query + get for detailed listing} *) 369 + 370 + let recent_cmd = 371 + let limit_term = 372 + let doc = "Number of recent emails to show (max 100)" in 373 + Arg.(value & opt int 100 & info ["limit"; "n"] ~docv:"N" ~doc) 374 + in 375 + let format_term = 376 + let doc = "Output format: table, compact, or detailed" in 377 + Arg.(value & opt (enum ["table", `Table; "compact", `Compact; "detailed", `Detailed]) 378 + `Table & info ["format"; "f"] ~docv:"FORMAT" ~doc) 379 + in 380 + let run cfg limit format = 381 + let limit = min limit 100 in 382 + Eio_main.run @@ fun env -> 383 + Eio.Switch.run @@ fun sw -> 384 + let client = Jmap_eio.Cli.create_client ~sw env cfg in 385 + let account_id = Jmap_eio.Cli.get_account_id cfg client in 386 + 387 + Jmap_eio.Cli.debug cfg "Fetching %d most recent emails" limit; 388 + 389 + (* Query for recent emails *) 390 + let sort = [Jmap_proto.Filter.comparator ~is_ascending:false "receivedAt"] in 391 + let query_inv = Jmap_eio.Client.Build.email_query 392 + ~call_id:"q1" 393 + ~account_id 394 + ~sort 395 + ~limit:(Int64.of_int limit) 396 + () 397 + in 398 + 399 + let req = Jmap_eio.Client.Build.( 400 + make_request 401 + ~capabilities:[Jmap_proto.Capability.core; Jmap_proto.Capability.mail] 402 + [query_inv] 403 + ) in 404 + 405 + match Jmap_eio.Client.request client req with 406 + | Error e -> 407 + Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e); 408 + exit 1 409 + | Ok response -> 410 + match Jmap_eio.Client.Parse.parse_email_query ~call_id:"q1" response with 411 + | Error e -> 412 + Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e); 413 + exit 1 414 + | Ok query_result -> 415 + let email_ids = query_result.ids in 416 + Jmap_eio.Cli.debug cfg "Query returned %d email IDs" (List.length email_ids); 417 + 418 + if List.length email_ids = 0 then ( 419 + Fmt.pr "No emails found.@." 420 + ) else ( 421 + (* Fetch full details for all emails *) 422 + let properties = [ 423 + "id"; "blobId"; "threadId"; "mailboxIds"; "keywords"; "size"; 424 + "receivedAt"; "subject"; "from"; "to"; "cc"; "preview" 425 + ] in 426 + let get_inv = Jmap_eio.Client.Build.email_get 427 + ~call_id:"g1" 428 + ~account_id 429 + ~ids:email_ids 430 + ~properties 431 + () 432 + in 433 + let req2 = Jmap_eio.Client.Build.( 434 + make_request 435 + ~capabilities:[Jmap_proto.Capability.core; Jmap_proto.Capability.mail] 436 + [get_inv] 437 + ) in 438 + 439 + Jmap_eio.Cli.debug cfg "Fetching email details..."; 440 + 441 + match Jmap_eio.Client.request client req2 with 442 + | Error e -> 443 + Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e); 444 + exit 1 445 + | Ok response2 -> 446 + match Jmap_eio.Client.Parse.parse_email_get ~call_id:"g1" response2 with 447 + | Error e -> 448 + Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e); 449 + exit 1 450 + | Ok get_result -> 451 + Jmap_eio.Cli.debug cfg "Got %d emails" (List.length get_result.list); 452 + 453 + (* Output based on format *) 454 + match format with 455 + | `Compact -> 456 + List.iter (fun (email : Jmap_mail.Email.t) -> 457 + let from_str = match email.from with 458 + | Some (addr :: _) -> 459 + Option.value addr.name ~default:addr.email 460 + | _ -> "?" 461 + in 462 + let subject = Option.value email.subject ~default:"(no subject)" in 463 + let flags = format_keywords email.keywords in 464 + 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) 467 + (truncate_string 20 from_str) 468 + (truncate_string 50 subject) 469 + flags 470 + ) get_result.list 471 + 472 + | `Table -> 473 + Fmt.pr "@[<v>%a (%d emails, state: %s)@,@," 474 + Fmt.(styled `Bold string) "Recent Emails" 475 + (List.length get_result.list) 476 + get_result.state; 477 + (* Header *) 478 + Fmt.pr "%-12s %-19s %-20s %-40s %s@," 479 + "ID" "Date" "From" "Subject" "Flags"; 480 + Fmt.pr "%s@," (String.make 110 '-'); 481 + List.iter (fun (email : Jmap_mail.Email.t) -> 482 + let from_str = match email.from with 483 + | Some (addr :: _) -> 484 + Option.value addr.name ~default:addr.email 485 + | _ -> "?" 486 + in 487 + let subject = Option.value email.subject ~default:"(no subject)" in 488 + let flags = format_keywords email.keywords in 489 + let id_short = 490 + let id = Jmap_proto.Id.to_string email.id in 491 + if String.length id > 12 then String.sub id 0 12 else id 492 + in 493 + Fmt.pr "%-12s %s %-20s %-40s %s@," 494 + id_short 495 + (ptime_to_string email.received_at) 496 + (truncate_string 20 from_str) 497 + (truncate_string 40 subject) 498 + flags 499 + ) get_result.list; 500 + Fmt.pr "@]@." 501 + 502 + | `Detailed -> 503 + Fmt.pr "@[<v>%a (%d emails)@,@," 504 + Fmt.(styled `Bold string) "Recent Emails" 505 + (List.length get_result.list); 506 + List.iteri (fun i (email : Jmap_mail.Email.t) -> 507 + let from_str = match email.from with 508 + | Some addrs -> format_email_addresses addrs 509 + | None -> "(unknown)" 510 + in 511 + let to_str = match email.to_ with 512 + | Some addrs -> format_email_addresses addrs 513 + | None -> "" 514 + in 515 + let cc_str = match email.cc with 516 + | Some addrs -> format_email_addresses addrs 517 + | None -> "" 518 + in 519 + 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 522 + 523 + Fmt.pr "@[<v 2>%a Email %d of %d@," 524 + Fmt.(styled `Bold string) "---" 525 + (i + 1) (List.length get_result.list); 526 + 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); 530 + Fmt.pr "From: %s@," from_str; 531 + if to_str <> "" then Fmt.pr "To: %s@," to_str; 532 + if cc_str <> "" then Fmt.pr "Cc: %s@," cc_str; 533 + Fmt.pr "Subject: %a@," 534 + Fmt.(styled `White string) subject; 535 + Fmt.pr "Size: %Ld bytes@," email.size; 536 + Fmt.pr "Mailboxes: %d@," mailbox_count; 537 + if flags <> "" then Fmt.pr "Flags: %s@," flags; 538 + Fmt.pr "Preview: %s@]@,@," email.preview; 539 + ) get_result.list; 540 + Fmt.pr "@]@." 541 + ) 542 + in 543 + let doc = "List recent emails with full details (chains query + get)" in 544 + let info = Cmd.info "recent" ~doc in 545 + Cmd.v info Term.(const run $ Jmap_eio.Cli.config_term $ limit_term $ format_term) 546 + 547 + (** {1 Threads Command} *) 548 + 549 + let threads_cmd = 550 + let email_id_term = 551 + let doc = "Email ID to get thread for" in 552 + Arg.(required & pos 0 (some string) None & info [] ~docv:"EMAIL_ID" ~doc) 553 + in 554 + let run cfg email_id_str = 555 + Eio_main.run @@ fun env -> 556 + Eio.Switch.run @@ fun sw -> 557 + let client = Jmap_eio.Cli.create_client ~sw env cfg in 558 + let account_id = Jmap_eio.Cli.get_account_id cfg client in 559 + 560 + let email_id = Jmap_proto.Id.of_string_exn email_id_str in 561 + 562 + (* First get the email to find its thread ID - include required properties *) 563 + let get_inv = Jmap_eio.Client.Build.email_get 564 + ~call_id:"e1" 565 + ~account_id 566 + ~ids:[email_id] 567 + ~properties:["id"; "blobId"; "threadId"; "mailboxIds"; "size"; "receivedAt"] 568 + () 569 + in 570 + let req = Jmap_eio.Client.Build.( 571 + make_request 572 + ~capabilities:[Jmap_proto.Capability.core; Jmap_proto.Capability.mail] 573 + [get_inv] 574 + ) in 575 + 576 + match Jmap_eio.Client.request client req with 577 + | Error e -> 578 + Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e); 579 + exit 1 580 + | Ok response -> 581 + match Jmap_eio.Client.Parse.parse_email_get ~call_id:"e1" response with 582 + | Error e -> 583 + Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e); 584 + exit 1 585 + | Ok email_result -> 586 + match email_result.list with 587 + | [] -> 588 + Fmt.epr "Email not found: %s@." email_id_str; 589 + exit 1 590 + | email :: _ -> 591 + let thread_id = email.thread_id in 592 + Jmap_eio.Cli.debug cfg "Thread ID: %s" (Jmap_proto.Id.to_string thread_id); 593 + 594 + (* Get the thread *) 595 + let thread_inv = Jmap_eio.Client.Build.thread_get 596 + ~call_id:"t1" 597 + ~account_id 598 + ~ids:[thread_id] 599 + () 600 + in 601 + let req2 = Jmap_eio.Client.Build.( 602 + make_request 603 + ~capabilities:[Jmap_proto.Capability.core; Jmap_proto.Capability.mail] 604 + [thread_inv] 605 + ) in 606 + 607 + match Jmap_eio.Client.request client req2 with 608 + | Error e -> 609 + Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e); 610 + exit 1 611 + | Ok response2 -> 612 + match Jmap_eio.Client.Parse.parse_thread_get ~call_id:"t1" response2 with 613 + | Error e -> 614 + Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e); 615 + exit 1 616 + | Ok thread_result -> 617 + match thread_result.list with 618 + | [] -> 619 + Fmt.epr "Thread not found@."; 620 + exit 1 621 + | thread :: _ -> 622 + let email_ids = thread.email_ids in 623 + Fmt.pr "@[<v>%a %s (%d emails)@,@," 624 + Fmt.(styled `Bold string) "Thread" 625 + (Jmap_proto.Id.to_string thread.id) 626 + (List.length email_ids); 627 + 628 + (* Fetch all emails in thread *) 629 + let get_inv2 = Jmap_eio.Client.Build.email_get 630 + ~call_id:"e2" 631 + ~account_id 632 + ~ids:email_ids 633 + ~properties:["id"; "blobId"; "threadId"; "mailboxIds"; "keywords"; 634 + "size"; "receivedAt"; "subject"; "from"; "preview"] 635 + () 636 + in 637 + let req3 = Jmap_eio.Client.Build.( 638 + make_request 639 + ~capabilities:[Jmap_proto.Capability.core; Jmap_proto.Capability.mail] 640 + [get_inv2] 641 + ) in 642 + 643 + match Jmap_eio.Client.request client req3 with 644 + | Error e -> 645 + Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e); 646 + exit 1 647 + | Ok response3 -> 648 + match Jmap_eio.Client.Parse.parse_email_get ~call_id:"e2" response3 with 649 + | Error e -> 650 + Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e); 651 + exit 1 652 + | Ok emails_result -> 653 + List.iter (fun (email : Jmap_mail.Email.t) -> 654 + let from_str = match email.from with 655 + | Some addrs -> format_email_addresses addrs 656 + | None -> "(unknown)" 657 + in 658 + let subject = Option.value email.subject ~default:"(no subject)" in 659 + Fmt.pr " %a %s@," 660 + Fmt.(styled `Cyan string) (Jmap_proto.Id.to_string email.id) 661 + (ptime_to_string email.received_at); 662 + Fmt.pr " From: %s@," (truncate_string 60 from_str); 663 + Fmt.pr " Subject: %a@,@," 664 + Fmt.(styled `White string) (truncate_string 60 subject); 665 + ) emails_result.list; 666 + Fmt.pr "@]@." 667 + in 668 + let doc = "Show email thread" in 669 + let info = Cmd.info "thread" ~doc in 670 + Cmd.v info Term.(const run $ Jmap_eio.Cli.config_term $ email_id_term) 671 + 672 + (** {1 Identities Command} *) 673 + 674 + let identities_cmd = 675 + let run cfg = 676 + Eio_main.run @@ fun env -> 677 + Eio.Switch.run @@ fun sw -> 678 + let client = Jmap_eio.Cli.create_client ~sw env cfg in 679 + let account_id = Jmap_eio.Cli.get_account_id cfg client in 680 + 681 + let req = Jmap_eio.Client.Build.( 682 + make_request 683 + ~capabilities:[Jmap_proto.Capability.core; Jmap_proto.Capability.mail; 684 + Jmap_proto.Capability.submission] 685 + [identity_get ~call_id:"i1" ~account_id ()] 686 + ) in 687 + 688 + match Jmap_eio.Client.request client req with 689 + | Error e -> 690 + Fmt.epr "Error: %s@." (Jmap_eio.Client.error_to_string e); 691 + exit 1 692 + | Ok response -> 693 + match Jmap_eio.Client.Parse.parse_response ~call_id:"i1" 694 + (Jmap_eio.Client.Parse.get_response Jmap_mail.Identity.jsont) 695 + response with 696 + | Error e -> 697 + Fmt.epr "Parse error: %s@." (Jsont.Error.to_string e); 698 + exit 1 699 + | Ok result -> 700 + Fmt.pr "@[<v>%a (state: %s)@,@," 701 + Fmt.(styled `Bold string) "Identities" 702 + result.state; 703 + List.iter (fun (ident : Jmap_mail.Identity.t) -> 704 + Fmt.pr " %a@," 705 + Fmt.(styled `Cyan string) (Jmap_proto.Id.to_string ident.id); 706 + Fmt.pr " Name: %s@," ident.name; 707 + 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 712 + ) result.list; 713 + Fmt.pr "@]@." 714 + in 715 + let doc = "List email identities" in 716 + let info = Cmd.info "identities" ~doc in 717 + Cmd.v info Term.(const run $ Jmap_eio.Cli.config_term) 718 + 719 + (** {1 Main Command Group} *) 720 + 721 + let main_cmd = 722 + let doc = "JMAP command-line client" in 723 + let man = [ 724 + `S Manpage.s_description; 725 + `P "A command-line client for JMAP (JSON Meta Application Protocol) email servers."; 726 + `S Manpage.s_environment; 727 + `P Jmap_eio.Cli.env_docs; 728 + `S Manpage.s_examples; 729 + `P "List mailboxes:"; 730 + `Pre " jmap mailboxes --url https://api.fastmail.com/jmap/session -k YOUR_API_KEY"; 731 + `P "Show recent emails:"; 732 + `Pre " jmap recent -n 50 --format detailed"; 733 + `P "Search emails:"; 734 + `Pre " jmap search \"meeting notes\" -n 10"; 735 + ] in 736 + let info = Cmd.info "jmap" ~version:"0.1.0" ~doc ~man in 737 + Cmd.group info [ 738 + session_cmd; 739 + mailboxes_cmd; 740 + emails_cmd; 741 + search_cmd; 742 + recent_cmd; 743 + threads_cmd; 744 + identities_cmd; 745 + ] 746 + 747 + let () = 748 + Fmt_tty.setup_std_outputs (); 749 + exit (Cmd.eval main_cmd)
-189
bin/jmap_test.ml
··· 1 - (*--------------------------------------------------------------------------- 2 - Copyright (c) 2025 Anil Madhavapeddy. All rights reserved. 3 - SPDX-License-Identifier: ISC 4 - ---------------------------------------------------------------------------*) 5 - 6 - (** JMAP test client - connects to a JMAP server and queries recent emails *) 7 - 8 - let ptime_to_string t = 9 - let (y, m, d), ((hh, mm, ss), _tz) = Ptime.to_date_time t in 10 - Printf.sprintf "%04d-%02d-%02d %02d:%02d:%02d" y m d hh mm ss 11 - 12 - let debug_mode = ref false 13 - 14 - let debug fmt = 15 - if !debug_mode then 16 - Printf.kfprintf (fun oc -> Printf.fprintf oc "\n%!") stderr ("[DEBUG] " ^^ fmt) 17 - else 18 - Printf.ifprintf stderr fmt 19 - 20 - let () = 21 - (* Parse command line arguments *) 22 - let usage = "Usage: jmap-test [--debug] <session-url> <api-key>" in 23 - let args = ref [] in 24 - let spec = [ 25 - ("--debug", Arg.Set debug_mode, "Enable debug output"); 26 - ("-d", Arg.Set debug_mode, "Enable debug output"); 27 - ] in 28 - Arg.parse spec (fun arg -> args := arg :: !args) usage; 29 - let session_url, api_key = 30 - match List.rev !args with 31 - | [url; key] -> (url, key) 32 - | _ -> 33 - prerr_endline usage; 34 - exit 1 35 - in 36 - 37 - debug "Session URL: %s" session_url; 38 - debug "API key length: %d chars" (String.length api_key); 39 - 40 - (* Run with Eio *) 41 - Eio_main.run @@ fun env -> 42 - Eio.Switch.run @@ fun sw -> 43 - 44 - (* Create HTTP client with Bearer token auth *) 45 - let requests = Requests.create ~sw env in 46 - let auth = Requests.Auth.bearer ~token:api_key in 47 - 48 - debug "Created HTTP client with Bearer auth"; 49 - Printf.printf "Connecting to %s...\n%!" session_url; 50 - 51 - (* Create JMAP client from session URL *) 52 - match Jmap_eio.Client.create_from_url ~auth requests session_url with 53 - | Error e -> 54 - Printf.eprintf "Failed to connect: %s\n" (Jmap_eio.Client.error_to_string e); 55 - exit 1 56 - | Ok client -> 57 - let session = Jmap_eio.Client.session client in 58 - debug "Session state: %s" session.state; 59 - debug "API URL: %s" session.api_url; 60 - debug "Upload URL: %s" session.upload_url; 61 - debug "Download URL: %s" session.download_url; 62 - debug "Capabilities: %s" (String.concat ", " (List.map fst session.capabilities)); 63 - debug "Accounts: %d" (List.length session.accounts); 64 - List.iter (fun (id, acct) -> 65 - debug " Account %s: %s (personal=%b, read_only=%b)" 66 - (Jmap_proto.Id.to_string id) 67 - acct.Jmap_proto.Session.Account.name 68 - acct.is_personal 69 - acct.is_read_only 70 - ) session.accounts; 71 - Printf.printf "Connected! Username: %s\n%!" session.username; 72 - 73 - (* Get primary mail account *) 74 - let primary_account_id = 75 - match Jmap_proto.Session.primary_account_for Jmap_proto.Capability.mail session with 76 - | Some id -> id 77 - | None -> 78 - prerr_endline "No primary mail account found"; 79 - exit 1 80 - in 81 - debug "Primary accounts: %s" 82 - (String.concat ", " (List.map (fun (cap, id) -> 83 - cap ^ "=" ^ Jmap_proto.Id.to_string id) session.primary_accounts)); 84 - Printf.printf "Primary mail account: %s\n%!" (Jmap_proto.Id.to_string primary_account_id); 85 - 86 - (* Query for recent emails - get the 10 most recent *) 87 - let sort = [Jmap_proto.Filter.comparator ~is_ascending:false "receivedAt"] in 88 - let query_inv = Jmap_eio.Client.Build.email_query 89 - ~call_id:"q1" 90 - ~account_id:primary_account_id 91 - ~sort 92 - ~limit:10L 93 - () 94 - in 95 - 96 - (* Build request with mail capability *) 97 - let req = Jmap_eio.Client.Build.make_request 98 - ~capabilities:[Jmap_proto.Capability.core; Jmap_proto.Capability.mail] 99 - [query_inv] 100 - in 101 - 102 - debug "Built Email/query request with sort by receivedAt desc, limit 10"; 103 - Printf.printf "Querying recent emails...\n%!"; 104 - 105 - match Jmap_eio.Client.request client req with 106 - | Error e -> 107 - Printf.eprintf "Query failed: %s\n" (Jmap_eio.Client.error_to_string e); 108 - exit 1 109 - | Ok response -> 110 - debug "Query response received, parsing..."; 111 - (* Parse the query response *) 112 - match Jmap_eio.Client.Parse.parse_email_query ~call_id:"q1" response with 113 - | Error e -> 114 - Printf.eprintf "Failed to parse query response: %s\n" (Jsont.Error.to_string e); 115 - exit 1 116 - | Ok query_result -> 117 - let email_ids = query_result.ids in 118 - debug "Query state: %s" query_result.query_state; 119 - debug "Can calculate updates: %b" query_result.can_calculate_changes; 120 - debug "Total results: %Ld" (Option.value query_result.total ~default:(-1L)); 121 - debug "Position: %Ld" query_result.position; 122 - debug "Email IDs: %s" 123 - (String.concat ", " (List.map Jmap_proto.Id.to_string email_ids)); 124 - Printf.printf "Found %d emails\n%!" (List.length email_ids); 125 - 126 - if List.length email_ids = 0 then ( 127 - Printf.printf "No emails found.\n%!"; 128 - ) else ( 129 - (* Fetch the email details - must include required properties *) 130 - let get_inv = Jmap_eio.Client.Build.email_get 131 - ~call_id:"g1" 132 - ~account_id:primary_account_id 133 - ~ids:email_ids 134 - ~properties:["id"; "blobId"; "threadId"; "mailboxIds"; "size"; 135 - "receivedAt"; "subject"; "from"; "preview"] 136 - () 137 - in 138 - 139 - let req2 = Jmap_eio.Client.Build.make_request 140 - ~capabilities:[Jmap_proto.Capability.core; Jmap_proto.Capability.mail] 141 - [get_inv] 142 - in 143 - 144 - debug "Built Email/get request for %d emails" (List.length email_ids); 145 - Printf.printf "Fetching email details...\n%!"; 146 - 147 - match Jmap_eio.Client.request client req2 with 148 - | Error e -> 149 - Printf.eprintf "Get failed: %s\n" (Jmap_eio.Client.error_to_string e); 150 - exit 1 151 - | Ok response2 -> 152 - debug "Get response received, parsing..."; 153 - match Jmap_eio.Client.Parse.parse_email_get ~call_id:"g1" response2 with 154 - | Error e -> 155 - Printf.eprintf "Failed to parse get response: %s\n" (Jsont.Error.to_string e); 156 - exit 1 157 - | Ok get_result -> 158 - debug "Get state: %s" get_result.state; 159 - debug "Emails returned: %d" (List.length get_result.list); 160 - debug "Not found: %d" (List.length get_result.not_found); 161 - Printf.printf "\n=== Recent Emails ===\n\n%!"; 162 - List.iter (fun (email : Jmap_mail.Email.t) -> 163 - let id = Jmap_proto.Id.to_string email.id in 164 - let subject = Option.value email.subject ~default:"(no subject)" in 165 - let from_addrs = Option.value email.from ~default:[] in 166 - let from_str = match from_addrs with 167 - | [] -> "(unknown sender)" 168 - | addr :: _ -> 169 - let name = Option.value addr.name ~default:"" in 170 - let email_addr = addr.email in 171 - if name = "" then email_addr 172 - else Printf.sprintf "%s <%s>" name email_addr 173 - in 174 - let received = ptime_to_string email.received_at in 175 - let preview = email.preview in 176 - let preview_short = 177 - if String.length preview > 80 then 178 - String.sub preview 0 77 ^ "..." 179 - else preview 180 - in 181 - Printf.printf "ID: %s\n" id; 182 - Printf.printf "From: %s\n" from_str; 183 - Printf.printf "Date: %s\n" received; 184 - Printf.printf "Subject: %s\n" subject; 185 - Printf.printf "Preview: %s\n" preview_short; 186 - Printf.printf "\n%!"; 187 - ) get_result.list; 188 - Printf.printf "=== End of emails ===\n%!" 189 - )
+1 -1
dune-project
··· 1 - (lang dune 3.0) 1 + (lang dune 3.20) 2 2 3 3 (name jmap) 4 4
+214
eio/cli.ml
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** JMAP CLI configuration and cmdliner terms *) 7 + 8 + open Cmdliner 9 + 10 + (** {1 Configuration Types} *) 11 + 12 + type source = Default | Env of string | Cmdline 13 + 14 + type config = { 15 + session_url : string; 16 + session_url_source : source; 17 + api_key : string; 18 + api_key_source : source; 19 + account_id : string option; 20 + account_id_source : source; 21 + debug : bool; 22 + } 23 + 24 + (** {1 Pretty Printing} *) 25 + 26 + let pp_source ppf = function 27 + | Default -> Fmt.(styled `Faint string) ppf "default" 28 + | Env var -> Fmt.pf ppf "%a" Fmt.(styled `Yellow string) ("env(" ^ var ^ ")") 29 + | Cmdline -> Fmt.(styled `Blue string) ppf "cmdline" 30 + 31 + let pp_config ppf cfg = 32 + let pp_field name value source = 33 + Fmt.pf ppf "@,%a %a %a" 34 + Fmt.(styled `Cyan string) (name ^ ":") 35 + Fmt.(styled `Green string) value 36 + Fmt.(styled `Faint (brackets pp_source)) source 37 + in 38 + let pp_opt_field name value_opt source = 39 + match value_opt with 40 + | None -> () 41 + | Some value -> pp_field name value source 42 + in 43 + Fmt.pf ppf "@[<v>%a" Fmt.(styled `Bold string) "JMAP config:"; 44 + pp_field "session_url" cfg.session_url cfg.session_url_source; 45 + pp_field "api_key" (String.make (min 8 (String.length cfg.api_key)) '*' ^ "...") cfg.api_key_source; 46 + pp_opt_field "account_id" cfg.account_id cfg.account_id_source; 47 + Fmt.pf ppf "@]" 48 + 49 + (** {1 Cmdliner Terms} *) 50 + 51 + let env_var_name suffix = "JMAP_" ^ suffix 52 + 53 + let resolve_with_env ~cmdline ~env_var ~default = 54 + match cmdline with 55 + | Some v -> (v, Cmdline) 56 + | None -> 57 + match Sys.getenv_opt env_var with 58 + | Some v when v <> "" -> (v, Env env_var) 59 + | _ -> (default, Default) 60 + 61 + let resolve_opt_with_env ~cmdline ~env_var = 62 + match cmdline with 63 + | Some v -> (Some v, Cmdline) 64 + | None -> 65 + match Sys.getenv_opt env_var with 66 + | Some v when v <> "" -> (Some v, Env env_var) 67 + | _ -> (None, Default) 68 + 69 + (** Session URL term *) 70 + let session_url_term = 71 + let doc = 72 + Printf.sprintf 73 + "JMAP session URL. Can also be set with %s environment variable." 74 + (env_var_name "SESSION_URL") 75 + in 76 + Arg.(value & opt (some string) None & info ["url"; "u"] ~docv:"URL" ~doc) 77 + 78 + (** API key term *) 79 + let api_key_term = 80 + let doc = 81 + Printf.sprintf 82 + "JMAP API key or Bearer token. Can also be set with %s environment variable." 83 + (env_var_name "API_KEY") 84 + in 85 + Arg.(value & opt (some string) None & info ["api-key"; "k"] ~docv:"KEY" ~doc) 86 + 87 + (** API key file term *) 88 + let api_key_file_term = 89 + let doc = 90 + Printf.sprintf 91 + "File containing JMAP API key. Can also be set with %s environment variable." 92 + (env_var_name "API_KEY_FILE") 93 + in 94 + Arg.(value & opt (some string) None & info ["api-key-file"; "K"] ~docv:"FILE" ~doc) 95 + 96 + (** Account ID term *) 97 + let account_id_term = 98 + let doc = 99 + Printf.sprintf 100 + "Account ID to use (defaults to primary mail account). Can also be set with %s." 101 + (env_var_name "ACCOUNT_ID") 102 + in 103 + Arg.(value & opt (some string) None & info ["account"; "a"] ~docv:"ID" ~doc) 104 + 105 + (** Debug flag term *) 106 + let debug_term = 107 + let doc = "Enable debug output" in 108 + Arg.(value & flag & info ["debug"; "d"] ~doc) 109 + 110 + (** Read API key from file *) 111 + let read_api_key_file path = 112 + try 113 + let ic = open_in path in 114 + let key = input_line ic in 115 + close_in ic; 116 + String.trim key 117 + with 118 + | Sys_error msg -> failwith (Printf.sprintf "Cannot read API key file: %s" msg) 119 + | End_of_file -> failwith "API key file is empty" 120 + 121 + (** Combined configuration term *) 122 + let config_term = 123 + let make session_url_opt api_key_opt api_key_file_opt account_id_opt debug = 124 + (* Resolve session URL *) 125 + let session_url, session_url_source = 126 + resolve_with_env 127 + ~cmdline:session_url_opt 128 + ~env_var:(env_var_name "SESSION_URL") 129 + ~default:"" 130 + in 131 + if session_url = "" then 132 + failwith "Session URL is required. Set via --url or JMAP_SESSION_URL"; 133 + 134 + (* Resolve API key - check key file first, then direct key *) 135 + let api_key, api_key_source = 136 + match api_key_file_opt with 137 + | Some path -> (read_api_key_file path, Cmdline) 138 + | None -> 139 + match Sys.getenv_opt (env_var_name "API_KEY_FILE") with 140 + | Some path when path <> "" -> (read_api_key_file path, Env (env_var_name "API_KEY_FILE")) 141 + | _ -> 142 + resolve_with_env 143 + ~cmdline:api_key_opt 144 + ~env_var:(env_var_name "API_KEY") 145 + ~default:"" 146 + in 147 + if api_key = "" then 148 + failwith "API key is required. Set via --api-key, --api-key-file, JMAP_API_KEY, or JMAP_API_KEY_FILE"; 149 + 150 + (* Resolve account ID (optional) *) 151 + let account_id, account_id_source = 152 + resolve_opt_with_env 153 + ~cmdline:account_id_opt 154 + ~env_var:(env_var_name "ACCOUNT_ID") 155 + in 156 + 157 + { session_url; session_url_source; 158 + api_key; api_key_source; 159 + account_id; account_id_source; 160 + debug } 161 + in 162 + Term.(const make $ session_url_term $ api_key_term $ api_key_file_term 163 + $ account_id_term $ debug_term) 164 + 165 + (** {1 Environment Documentation} *) 166 + 167 + let env_docs = 168 + {| 169 + Environment Variables: 170 + JMAP_SESSION_URL JMAP session URL (e.g., https://api.fastmail.com/jmap/session) 171 + JMAP_API_KEY API key or Bearer token for authentication 172 + JMAP_API_KEY_FILE Path to file containing API key 173 + JMAP_ACCOUNT_ID Account ID to use (optional, defaults to primary mail account) 174 + 175 + Configuration Precedence: 176 + 1. Command-line flags (e.g., --url, --api-key) 177 + 2. Environment variables (e.g., JMAP_SESSION_URL) 178 + 179 + Example: 180 + export JMAP_SESSION_URL="https://api.fastmail.com/jmap/session" 181 + export JMAP_API_KEY_FILE="$HOME/.jmap-api-key" 182 + jmap emails --limit 10 183 + |} 184 + 185 + (** {1 Client Helpers} *) 186 + 187 + let create_client ~sw env cfg = 188 + let requests = Requests.create ~sw env in 189 + let auth = Requests.Auth.bearer ~token:cfg.api_key in 190 + match Client.create_from_url ~auth requests cfg.session_url with 191 + | Error e -> 192 + Fmt.epr "@[<v>%a Failed to connect: %s@]@." 193 + Fmt.(styled `Red string) "Error:" 194 + (Client.error_to_string e); 195 + exit 1 196 + | Ok client -> client 197 + 198 + let get_account_id cfg client = 199 + match cfg.account_id with 200 + | Some id -> Jmap_proto.Id.of_string_exn id 201 + | None -> 202 + let session = Client.session client in 203 + match Jmap_proto.Session.primary_account_for Jmap_proto.Capability.mail session with 204 + | Some id -> id 205 + | None -> 206 + Fmt.epr "@[<v>%a No primary mail account found. Specify --account.@]@." 207 + Fmt.(styled `Red string) "Error:"; 208 + exit 1 209 + 210 + let debug cfg fmt = 211 + if cfg.debug then 212 + Fmt.kpf (fun ppf -> Fmt.pf ppf "@.") Fmt.stderr ("@[<h>[DEBUG] " ^^ fmt ^^ "@]") 213 + else 214 + Format.ikfprintf ignore Format.err_formatter fmt
+94
eio/cli.mli
··· 1 + (*--------------------------------------------------------------------------- 2 + Copyright (c) 2025 Anil Madhavapeddy. All rights reserved. 3 + SPDX-License-Identifier: ISC 4 + ---------------------------------------------------------------------------*) 5 + 6 + (** JMAP CLI configuration and cmdliner terms 7 + 8 + This module provides reusable cmdliner terms for building JMAP CLI tools. 9 + It handles configuration from command-line arguments and environment 10 + variables with proper precedence. 11 + 12 + {2 Environment Variables} 13 + 14 + - [JMAP_SESSION_URL] - JMAP session URL 15 + - [JMAP_API_KEY] - API key or Bearer token 16 + - [JMAP_API_KEY_FILE] - Path to file containing API key 17 + - [JMAP_ACCOUNT_ID] - Account ID (optional) 18 + 19 + {2 Configuration Precedence} 20 + 21 + 1. Command-line flags (e.g., [--url], [--api-key]) 22 + 2. Environment variables (e.g., [JMAP_SESSION_URL]) 23 + 3. Default values (where applicable) 24 + *) 25 + 26 + (** {1 Configuration Types} *) 27 + 28 + (** Source of a configuration value. *) 29 + type source = 30 + | Default (** Value from default *) 31 + | Env of string (** Value from environment variable *) 32 + | Cmdline (** Value from command line *) 33 + 34 + (** CLI configuration with source tracking. *) 35 + type config = { 36 + session_url : string; 37 + session_url_source : source; 38 + api_key : string; 39 + api_key_source : source; 40 + account_id : string option; 41 + account_id_source : source; 42 + debug : bool; 43 + } 44 + 45 + (** {1 Pretty Printing} *) 46 + 47 + val pp_source : source Fmt.t 48 + (** Pretty-print a configuration source. *) 49 + 50 + val pp_config : config Fmt.t 51 + (** Pretty-print the configuration (with masked API key). *) 52 + 53 + (** {1 Cmdliner Terms} *) 54 + 55 + val config_term : config Cmdliner.Term.t 56 + (** Combined cmdliner term for JMAP configuration. 57 + Includes session URL, API key (direct or from file), account ID, and debug flag. *) 58 + 59 + val session_url_term : string option Cmdliner.Term.t 60 + (** Cmdliner term for session URL. *) 61 + 62 + val api_key_term : string option Cmdliner.Term.t 63 + (** Cmdliner term for API key. *) 64 + 65 + val api_key_file_term : string option Cmdliner.Term.t 66 + (** Cmdliner term for API key file path. *) 67 + 68 + val account_id_term : string option Cmdliner.Term.t 69 + (** Cmdliner term for account ID. *) 70 + 71 + val debug_term : bool Cmdliner.Term.t 72 + (** Cmdliner term for debug flag. *) 73 + 74 + (** {1 Environment Documentation} *) 75 + 76 + val env_docs : string 77 + (** Documentation string describing environment variables for use in man pages. *) 78 + 79 + (** {1 Client Helpers} *) 80 + 81 + val create_client : 82 + sw:Eio.Switch.t -> 83 + Eio_unix.Stdenv.base -> 84 + config -> 85 + Client.t 86 + (** [create_client ~sw env cfg] creates a JMAP client from the configuration. 87 + Exits with error message on connection failure. *) 88 + 89 + val get_account_id : config -> Client.t -> Jmap_proto.Id.t 90 + (** [get_account_id cfg client] returns the account ID from config, or the 91 + primary mail account if not specified. Exits with error if no account found. *) 92 + 93 + val debug : config -> ('a, Format.formatter, unit) format -> 'a 94 + (** [debug cfg fmt ...] prints a debug message to stderr if debug mode is enabled. *)
+2 -2
eio/dune
··· 1 1 (library 2 2 (name jmap_eio) 3 3 (public_name jmap-eio) 4 - (libraries jmap jmap.mail jsont jsont.bytesrw eio requests uri str) 5 - (modules jmap_eio codec client)) 4 + (libraries jmap jmap.mail jsont jsont.bytesrw eio requests uri str cmdliner fmt.tty) 5 + (modules jmap_eio codec client cli))
+1
eio/jmap_eio.ml
··· 5 5 6 6 module Codec = Codec 7 7 module Client = Client 8 + module Cli = Cli
+3
eio/jmap_eio.mli
··· 71 71 72 72 (** High-level JMAP client with session management. *) 73 73 module Client = Client 74 + 75 + (** CLI configuration and cmdliner terms for JMAP tools. *) 76 + module Cli = Cli
+2 -1
jmap-eio.opam
··· 10 10 doc: "https://avsm.github.io/ocaml-jmap" 11 11 bug-reports: "https://github.com/avsm/ocaml-jmap/issues" 12 12 depends: [ 13 - "dune" {>= "3.0"} 13 + "dune" {>= "3.20"} 14 14 "ocaml" {>= "5.4.0"} 15 15 "jmap" {= version} 16 16 "jsont" {>= "0.2.0"} ··· 33 33 ] 34 34 ] 35 35 dev-repo: "git+https://github.com/avsm/ocaml-jmap.git" 36 + x-maintenance-intent: ["(latest)"]
+2 -1
jmap.opam
··· 10 10 doc: "https://avsm.github.io/ocaml-jmap" 11 11 bug-reports: "https://github.com/avsm/ocaml-jmap/issues" 12 12 depends: [ 13 - "dune" {>= "3.0"} 13 + "dune" {>= "3.20"} 14 14 "ocaml" {>= "5.4.0"} 15 15 "jsont" {>= "0.2.0"} 16 16 "json-pointer" ··· 32 32 ] 33 33 ] 34 34 dev-repo: "git+https://github.com/avsm/ocaml-jmap.git" 35 + x-maintenance-intent: ["(latest)"]
+23 -10
proto/mail/email.ml
··· 82 82 let jsont = 83 83 let kind = "Email" in 84 84 let body_values_jsont = Jmap_proto.Json_map.of_string Email_body.Value.jsont in 85 + (* subject can be null per RFC 8621 Section 4.1.1 *) 86 + let nullable_string = Jsont.(option string) in 85 87 Jsont.Object.map ~kind make 86 88 |> Jsont.Object.mem "id" Jmap_proto.Id.jsont ~enc:id 87 89 |> Jsont.Object.mem "blobId" Jmap_proto.Id.jsont ~enc:blob_id ··· 90 92 |> Jsont.Object.mem "receivedAt" Jmap_proto.Date.Utc.jsont ~enc:received_at 91 93 |> Jsont.Object.mem "mailboxIds" Jmap_proto.Json_map.id_to_bool ~enc:mailbox_ids 92 94 |> Jsont.Object.mem "keywords" Jmap_proto.Json_map.string_to_bool ~dec_absent:[] ~enc:keywords 93 - |> Jsont.Object.opt_mem "messageId" (Jsont.list Jsont.string) ~enc:message_id 94 - |> Jsont.Object.opt_mem "inReplyTo" (Jsont.list Jsont.string) ~enc:in_reply_to 95 - |> Jsont.Object.opt_mem "references" (Jsont.list Jsont.string) ~enc:references 96 - |> Jsont.Object.opt_mem "sender" (Jsont.list Email_address.jsont) ~enc:sender 97 - |> Jsont.Object.opt_mem "from" (Jsont.list Email_address.jsont) ~enc:from 98 - |> Jsont.Object.opt_mem "to" (Jsont.list Email_address.jsont) ~enc:to_ 99 - |> Jsont.Object.opt_mem "cc" (Jsont.list Email_address.jsont) ~enc:cc 100 - |> Jsont.Object.opt_mem "bcc" (Jsont.list Email_address.jsont) ~enc:bcc 101 - |> Jsont.Object.opt_mem "replyTo" (Jsont.list Email_address.jsont) ~enc:reply_to 102 - |> Jsont.Object.opt_mem "subject" Jsont.string ~enc:subject 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 Email_address.jsont)) 103 + ~dec_absent:None ~enc_omit:Option.is_none ~enc:sender 104 + |> Jsont.Object.mem "from" Jsont.(option (list Email_address.jsont)) 105 + ~dec_absent:None ~enc_omit:Option.is_none ~enc:from 106 + |> Jsont.Object.mem "to" Jsont.(option (list Email_address.jsont)) 107 + ~dec_absent:None ~enc_omit:Option.is_none ~enc:to_ 108 + |> Jsont.Object.mem "cc" Jsont.(option (list Email_address.jsont)) 109 + ~dec_absent:None ~enc_omit:Option.is_none ~enc:cc 110 + |> Jsont.Object.mem "bcc" Jsont.(option (list Email_address.jsont)) 111 + ~dec_absent:None ~enc_omit:Option.is_none ~enc:bcc 112 + |> Jsont.Object.mem "replyTo" Jsont.(option (list Email_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 103 116 |> Jsont.Object.opt_mem "sentAt" Jmap_proto.Date.Rfc3339.jsont ~enc:sent_at 104 117 |> Jsont.Object.opt_mem "headers" (Jsont.list Email_header.jsont) ~enc:headers 105 118 |> Jsont.Object.opt_mem "bodyStructure" Email_body.Part.jsont ~enc:body_structure
+4 -1
proto/mail/email_address.ml
··· 24 24 25 25 let jsont = 26 26 let kind = "EmailAddress" in 27 + (* name can be absent, null, or a string - all map to string option *) 28 + (* Jsont.option maps null -> None and string -> Some string *) 27 29 Jsont.Object.map ~kind make 28 - |> Jsont.Object.opt_mem "name" Jsont.string ~enc:name 30 + |> Jsont.Object.mem "name" Jsont.(option string) 31 + ~dec_absent:None ~enc_omit:Option.is_none ~enc:name 29 32 |> Jsont.Object.mem "email" Jsont.string ~enc:email 30 33 |> Jsont.Object.finish 31 34
+17 -7
proto/mail/email_body.ml
··· 64 64 { part_id; blob_id; size; headers; name; type_; charset; disposition; 65 65 cid; language; location; sub_parts } 66 66 in 67 + (* Many fields can be null per RFC 8621 Section 4.1.4 *) 68 + let nullable_string = Jsont.(option string) in 69 + let nullable_id = Jsont.(option Jmap_proto.Id.jsont) in 67 70 lazy ( 68 71 Jsont.Object.map ~kind make 69 - |> Jsont.Object.opt_mem "partId" Jsont.string ~enc:part_id 70 - |> Jsont.Object.opt_mem "blobId" Jmap_proto.Id.jsont ~enc:blob_id 72 + |> Jsont.Object.mem "partId" nullable_string 73 + ~dec_absent:None ~enc_omit:Option.is_none ~enc:part_id 74 + |> Jsont.Object.mem "blobId" nullable_id 75 + ~dec_absent:None ~enc_omit:Option.is_none ~enc:blob_id 71 76 |> Jsont.Object.opt_mem "size" Jmap_proto.Int53.Unsigned.jsont ~enc:size 72 77 |> Jsont.Object.opt_mem "headers" (Jsont.list Email_header.jsont) ~enc:headers 73 - |> Jsont.Object.opt_mem "name" Jsont.string ~enc:name 78 + |> Jsont.Object.mem "name" nullable_string 79 + ~dec_absent:None ~enc_omit:Option.is_none ~enc:name 74 80 |> Jsont.Object.mem "type" Jsont.string ~enc:type_ 75 - |> Jsont.Object.opt_mem "charset" Jsont.string ~enc:charset 76 - |> Jsont.Object.opt_mem "disposition" Jsont.string ~enc:disposition 77 - |> Jsont.Object.opt_mem "cid" Jsont.string ~enc:cid 81 + |> Jsont.Object.mem "charset" nullable_string 82 + ~dec_absent:None ~enc_omit:Option.is_none ~enc:charset 83 + |> Jsont.Object.mem "disposition" nullable_string 84 + ~dec_absent:None ~enc_omit:Option.is_none ~enc:disposition 85 + |> Jsont.Object.mem "cid" nullable_string 86 + ~dec_absent:None ~enc_omit:Option.is_none ~enc:cid 78 87 |> Jsont.Object.opt_mem "language" (Jsont.list Jsont.string) ~enc:language 79 - |> Jsont.Object.opt_mem "location" Jsont.string ~enc:location 88 + |> Jsont.Object.mem "location" nullable_string 89 + ~dec_absent:None ~enc_omit:Option.is_none ~enc:location 80 90 |> Jsont.Object.opt_mem "subParts" (Jsont.list (Jsont.rec' jsont)) ~enc:sub_parts 81 91 |> Jsont.Object.finish 82 92 )
+14 -5
proto/mail/mailbox.ml
··· 124 124 125 125 let jsont = 126 126 let kind = "Mailbox" in 127 + (* parentId and role can be null - RFC 8621 Section 2 *) 128 + let nullable_id = Jsont.(option Jmap_proto.Id.jsont) in 129 + let nullable_role = Jsont.(option role_jsont) in 127 130 Jsont.Object.map ~kind make 128 131 |> Jsont.Object.mem "id" Jmap_proto.Id.jsont ~enc:id 129 132 |> Jsont.Object.mem "name" Jsont.string ~enc:name 130 - |> Jsont.Object.opt_mem "parentId" Jmap_proto.Id.jsont ~enc:parent_id 131 - |> Jsont.Object.opt_mem "role" role_jsont ~enc:role 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 132 137 |> Jsont.Object.mem "sortOrder" Jmap_proto.Int53.Unsigned.jsont ~dec_absent:0L ~enc:sort_order 133 138 |> Jsont.Object.mem "totalEmails" Jmap_proto.Int53.Unsigned.jsont ~enc:total_emails 134 139 |> Jsont.Object.mem "unreadEmails" Jmap_proto.Int53.Unsigned.jsont ~enc:unread_emails ··· 152 157 153 158 let jsont = 154 159 let kind = "MailboxFilterCondition" in 155 - (* parentId can be null (meaning top-level) or an id *) 156 - let nullable_id = Jsont.(some Jmap_proto.Id.jsont) in 157 - let nullable_role = Jsont.(some role_jsont) in 160 + (* parentId and role can be absent, null, or have a value - RFC 8621 Section 2.1 *) 161 + (* Use opt_mem with Jsont.option to get option option type: 162 + - None = field absent (don't filter) 163 + - Some None = field present with null (filter for no parent/role) 164 + - Some (Some x) = field present with value (filter for specific value) *) 165 + let nullable_id = Jsont.(option Jmap_proto.Id.jsont) in 166 + let nullable_role = Jsont.(option role_jsont) in 158 167 Jsont.Object.map ~kind make 159 168 |> Jsont.Object.opt_mem "parentId" nullable_id ~enc:(fun f -> f.parent_id) 160 169 |> Jsont.Object.opt_mem "name" Jsont.string ~enc:(fun f -> f.name)
+6 -2
proto/mail/search_snippet.ml
··· 17 17 18 18 let jsont = 19 19 let kind = "SearchSnippet" in 20 + (* subject and preview can be null per RFC 8621 Section 5 *) 21 + let nullable_string = Jsont.(option string) in 20 22 Jsont.Object.map ~kind make 21 23 |> Jsont.Object.mem "emailId" Jmap_proto.Id.jsont ~enc:email_id 22 - |> Jsont.Object.opt_mem "subject" Jsont.string ~enc:subject 23 - |> Jsont.Object.opt_mem "preview" Jsont.string ~enc:preview 24 + |> Jsont.Object.mem "subject" nullable_string 25 + ~dec_absent:None ~enc_omit:Option.is_none ~enc:subject 26 + |> Jsont.Object.mem "preview" nullable_string 27 + ~dec_absent:None ~enc_omit:Option.is_none ~enc:preview 24 28 |> Jsont.Object.finish
+8 -3
proto/mail/vacation.ml
··· 28 28 29 29 let jsont = 30 30 let kind = "VacationResponse" in 31 + (* subject, textBody, htmlBody can be null per RFC 8621 Section 8 *) 32 + let nullable_string = Jsont.(option string) in 31 33 Jsont.Object.map ~kind make 32 34 |> Jsont.Object.mem "id" Jmap_proto.Id.jsont ~enc:id 33 35 |> Jsont.Object.mem "isEnabled" Jsont.bool ~enc:is_enabled 34 36 |> Jsont.Object.opt_mem "fromDate" Jmap_proto.Date.Utc.jsont ~enc:from_date 35 37 |> Jsont.Object.opt_mem "toDate" Jmap_proto.Date.Utc.jsont ~enc:to_date 36 - |> Jsont.Object.opt_mem "subject" Jsont.string ~enc:subject 37 - |> Jsont.Object.opt_mem "textBody" Jsont.string ~enc:text_body 38 - |> Jsont.Object.opt_mem "htmlBody" Jsont.string ~enc:html_body 38 + |> Jsont.Object.mem "subject" nullable_string 39 + ~dec_absent:None ~enc_omit:Option.is_none ~enc:subject 40 + |> Jsont.Object.mem "textBody" nullable_string 41 + ~dec_absent:None ~enc_omit:Option.is_none ~enc:text_body 42 + |> Jsont.Object.mem "htmlBody" nullable_string 43 + ~dec_absent:None ~enc_omit:Option.is_none ~enc:html_body 39 44 |> Jsont.Object.finish
+3 -2
proto/method_.ml
··· 137 137 { account_id; old_state; new_state; created; updated; destroyed; 138 138 not_created; not_updated; not_destroyed } 139 139 in 140 - (* For updated values, the server may return null or an object *) 141 - let nullable_obj = Jsont.(some obj_jsont) in 140 + (* For updated values, the server may return null or an object - RFC 8620 Section 5.3 *) 141 + (* "Id[Foo|null]" means map values can be null, use Jsont.option to handle this *) 142 + let nullable_obj = Jsont.(option obj_jsont) in 142 143 Jsont.Object.map ~kind make 143 144 |> Jsont.Object.mem "accountId" Id.jsont ~enc:(fun r -> r.account_id) 144 145 |> Jsont.Object.opt_mem "oldState" Jsont.string ~enc:(fun r -> r.old_state)