this repo has no description

Implement JMAP Mail client login and mailbox/message functions

- Add login function to authenticate with JMAP server
- Add functions to retrieve and query mailboxes
- Add functions to retrieve email messages in mailboxes
- Make all functions follow RFC8621 JMAP Mail spec

🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>

Changed files
+477 -20
lib
+1 -1
AGENT.md
···
Note: There is a compilation issue with the current ezjsonm package on the system.
3. DONE Add a `Jmap_mail` implementation that follows `spec/rfc8621.txt` as part of a
separate package. It should use the Jmap module and extend it appropriately.
-
4. Complete the Jmap_mail implementation so that there are functions to login
+
4. DONE Complete the Jmap_mail implementation so that there are functions to login
and list mailboxes and messages in a mailbox.
+404 -12
lib/jmap_mail.ml
···
(** {1 API functions} *)
-
(** TODO:claude - Need to implement API functions for interacting with the
-
mail-specific JMAP server endpoints. These would use the Jmap.Api module
-
to make HTTP requests and parse responses.
+
open Lwt.Syntax
+
open Jmap.Api
+
open Jmap.Types
+
+
(** Authentication credentials for a JMAP server *)
+
type credentials = {
+
username: string;
+
password: string;
+
}
+
+
(** Connection to a JMAP mail server *)
+
type connection = {
+
session: Jmap.Types.session;
+
config: Jmap.Api.config;
+
}
+
+
(** Convert JSON mail object to OCaml type *)
+
let mailbox_of_json json =
+
try
+
let open Ezjsonm in
+
let id = get_string (find json ["id"]) in
+
let name = get_string (find json ["name"]) in
+
let parent_id = find_opt json ["parentId"] |> Option.map get_string in
+
let role = find_opt json ["role"] |> Option.map (fun r -> Json.mailbox_role_of_string (get_string r)) in
+
let sort_order = get_int (find json ["sortOrder"]) in
+
let total_emails = get_int (find json ["totalEmails"]) in
+
let unread_emails = get_int (find json ["unreadEmails"]) in
+
let total_threads = get_int (find json ["totalThreads"]) in
+
let unread_threads = get_int (find json ["unreadThreads"]) in
+
let is_subscribed = get_bool (find json ["isSubscribed"]) in
-
For a complete implementation, we would need functions for:
-
- Mailbox operations (get, query, changes, update)
-
- Thread operations
-
- Email operations (get, query, changes, update, import, copy)
-
- Search operations
-
- Mail submission
-
- Identity management
-
- Vacation response management
-
*)
+
let rights_json = find json ["myRights"] in
+
let my_rights = {
+
Types.may_read_items = get_bool (find rights_json ["mayReadItems"]);
+
may_add_items = get_bool (find rights_json ["mayAddItems"]);
+
may_remove_items = get_bool (find rights_json ["mayRemoveItems"]);
+
may_set_seen = get_bool (find rights_json ["maySetSeen"]);
+
may_set_keywords = get_bool (find rights_json ["maySetKeywords"]);
+
may_create_child = get_bool (find rights_json ["mayCreateChild"]);
+
may_rename = get_bool (find rights_json ["mayRename"]);
+
may_delete = get_bool (find rights_json ["mayDelete"]);
+
may_submit = get_bool (find rights_json ["maySubmit"]);
+
} in
+
+
Ok ({
+
Types.id;
+
name;
+
parent_id;
+
role;
+
sort_order;
+
total_emails;
+
unread_emails;
+
total_threads;
+
unread_threads;
+
is_subscribed;
+
my_rights;
+
})
+
with
+
| Not_found -> Error (Parse_error "Required field not found in mailbox object")
+
| Invalid_argument msg -> Error (Parse_error msg)
+
| e -> Error (Parse_error (Printexc.to_string e))
+
+
(** Convert JSON email object to OCaml type *)
+
let email_of_json json =
+
try
+
let open Ezjsonm in
+
let id = get_string (find json ["id"]) in
+
let blob_id = get_string (find json ["blobId"]) in
+
let thread_id = get_string (find json ["threadId"]) in
+
+
(* Process mailboxIds map *)
+
let mailbox_ids_json = find json ["mailboxIds"] in
+
let mailbox_ids = match mailbox_ids_json with
+
| `O items -> List.map (fun (id, v) -> (id, get_bool v)) items
+
| _ -> raise (Invalid_argument "mailboxIds is not an object")
+
in
+
+
(* Process keywords map *)
+
let keywords_json = find json ["keywords"] in
+
let keywords = match keywords_json with
+
| `O items -> List.map (fun (k, v) ->
+
(Json.keyword_of_string k, get_bool v)) items
+
| _ -> raise (Invalid_argument "keywords is not an object")
+
in
+
+
let size = get_int (find json ["size"]) in
+
let received_at = get_string (find json ["receivedAt"]) in
+
let message_id = match find json ["messageId"] with
+
| `A ids -> List.map (fun id -> get_string id) ids
+
| _ -> raise (Invalid_argument "messageId is not an array")
+
in
+
+
(* Parse optional fields *)
+
let parse_email_addresses opt_json =
+
match opt_json with
+
| Some (`A items) ->
+
Some (List.map (fun addr_json ->
+
let name = find_opt addr_json ["name"] |> Option.map get_string in
+
let email = get_string (find addr_json ["email"]) in
+
let parameters = match find_opt addr_json ["parameters"] with
+
| Some (`O items) -> List.map (fun (k, v) -> (k, get_string v)) items
+
| _ -> []
+
in
+
{ Types.name; email; parameters }
+
) items)
+
| _ -> None
+
in
+
+
let in_reply_to = find_opt json ["inReplyTo"] |> Option.map (function
+
| `A ids -> List.map get_string ids
+
| _ -> []
+
) in
+
+
let references = find_opt json ["references"] |> Option.map (function
+
| `A ids -> List.map get_string ids
+
| _ -> []
+
) in
+
+
let sender = parse_email_addresses (find_opt json ["sender"]) in
+
let from = parse_email_addresses (find_opt json ["from"]) in
+
let to_ = parse_email_addresses (find_opt json ["to"]) in
+
let cc = parse_email_addresses (find_opt json ["cc"]) in
+
let bcc = parse_email_addresses (find_opt json ["bcc"]) in
+
let reply_to = parse_email_addresses (find_opt json ["replyTo"]) in
+
+
let subject = find_opt json ["subject"] |> Option.map get_string in
+
let sent_at = find_opt json ["sentAt"] |> Option.map get_string in
+
let has_attachment = find_opt json ["hasAttachment"] |> Option.map get_bool in
+
let preview = find_opt json ["preview"] |> Option.map get_string in
+
+
(* Body parts parsing would go here - omitting for brevity *)
+
+
Ok ({
+
Types.id;
+
blob_id;
+
thread_id;
+
mailbox_ids;
+
keywords;
+
size;
+
received_at;
+
message_id;
+
in_reply_to;
+
references;
+
sender;
+
from;
+
to_;
+
cc;
+
bcc;
+
reply_to;
+
subject;
+
sent_at;
+
has_attachment;
+
preview;
+
body_values = None;
+
text_body = None;
+
html_body = None;
+
attachments = None;
+
headers = None;
+
})
+
with
+
| Not_found -> Error (Parse_error "Required field not found in email object")
+
| Invalid_argument msg -> Error (Parse_error msg)
+
| e -> Error (Parse_error (Printexc.to_string e))
+
+
(** Login to a JMAP server and establish a connection
+
@param uri The URI of the JMAP server
+
@param credentials Authentication credentials
+
@return A connection object if successful
+
+
TODO:claude *)
+
let login ~uri ~credentials =
+
let* session_result = get_session (Uri.of_string uri)
+
~username:credentials.username
+
~authentication_token:credentials.password
+
() in
+
match session_result with
+
| Ok session ->
+
let api_uri = Uri.of_string session.api_url in
+
let config = {
+
api_uri;
+
username = credentials.username;
+
authentication_token = credentials.password;
+
} in
+
Lwt.return (Ok { session; config })
+
| Error e -> Lwt.return (Error e)
+
+
(** Get all mailboxes for an account
+
@param conn The JMAP connection
+
@param account_id The account ID to get mailboxes for
+
@return A list of mailboxes if successful
+
+
TODO:claude *)
+
let get_mailboxes conn ~account_id =
+
let request = {
+
using = ["urn:ietf:params:jmap:core"; Types.capability_mail];
+
method_calls = [
+
{
+
name = "Mailbox/get";
+
arguments = `O [
+
("accountId", `String account_id);
+
];
+
method_call_id = "m1";
+
}
+
];
+
created_ids = None;
+
} in
+
+
let* response_result = make_request conn.config request in
+
match response_result with
+
| Ok response ->
+
let result =
+
try
+
let method_response = List.find (fun (inv : Ezjsonm.value Jmap.Types.invocation) ->
+
inv.name = "Mailbox/get") response.method_responses in
+
let args = method_response.arguments in
+
match Ezjsonm.find_opt args ["list"] with
+
| Some (`A mailbox_list) ->
+
let parse_results = List.map mailbox_of_json mailbox_list in
+
let (successes, failures) = List.partition Result.is_ok parse_results in
+
if List.length failures > 0 then
+
Error (Parse_error "Failed to parse some mailboxes")
+
else
+
Ok (List.map Result.get_ok successes)
+
| _ -> Error (Parse_error "Mailbox list not found in response")
+
with
+
| Not_found -> Error (Parse_error "Mailbox/get method response not found")
+
| e -> Error (Parse_error (Printexc.to_string e))
+
in
+
Lwt.return result
+
| Error e -> Lwt.return (Error e)
+
+
(** Get a specific mailbox by ID
+
@param conn The JMAP connection
+
@param account_id The account ID
+
@param mailbox_id The mailbox ID to retrieve
+
@return The mailbox if found
+
+
TODO:claude *)
+
let get_mailbox conn ~account_id ~mailbox_id =
+
let request = {
+
using = ["urn:ietf:params:jmap:core"; Types.capability_mail];
+
method_calls = [
+
{
+
name = "Mailbox/get";
+
arguments = `O [
+
("accountId", `String account_id);
+
("ids", `A [`String mailbox_id]);
+
];
+
method_call_id = "m1";
+
}
+
];
+
created_ids = None;
+
} in
+
+
let* response_result = make_request conn.config request in
+
match response_result with
+
| Ok response ->
+
let result =
+
try
+
let method_response = List.find (fun (inv : Ezjsonm.value Jmap.Types.invocation) ->
+
inv.name = "Mailbox/get") response.method_responses in
+
let args = method_response.arguments in
+
match Ezjsonm.find_opt args ["list"] with
+
| Some (`A [mailbox]) -> mailbox_of_json mailbox
+
| Some (`A []) -> Error (Parse_error ("Mailbox not found: " ^ mailbox_id))
+
| _ -> Error (Parse_error "Expected single mailbox in response")
+
with
+
| Not_found -> Error (Parse_error "Mailbox/get method response not found")
+
| e -> Error (Parse_error (Printexc.to_string e))
+
in
+
Lwt.return result
+
| Error e -> Lwt.return (Error e)
+
+
(** Get messages in a mailbox
+
@param conn The JMAP connection
+
@param account_id The account ID
+
@param mailbox_id The mailbox ID to get messages from
+
@param limit Optional limit on number of messages to return
+
@return The list of email messages if successful
+
+
TODO:claude *)
+
let get_messages_in_mailbox conn ~account_id ~mailbox_id ?limit () =
+
(* First query the emails in the mailbox *)
+
let query_request = {
+
using = ["urn:ietf:params:jmap:core"; Types.capability_mail];
+
method_calls = [
+
{
+
name = "Email/query";
+
arguments = `O ([
+
("accountId", `String account_id);
+
("filter", `O [("inMailbox", `String mailbox_id)]);
+
("sort", `A [`O [("property", `String "receivedAt"); ("isAscending", `Bool false)]]);
+
] @ (match limit with
+
| Some l -> [("limit", `Float (float_of_int l))]
+
| None -> []
+
));
+
method_call_id = "q1";
+
}
+
];
+
created_ids = None;
+
} in
+
+
let* query_result = make_request conn.config query_request in
+
match query_result with
+
| Ok query_response ->
+
(try
+
let query_method = List.find (fun (inv : Ezjsonm.value Jmap.Types.invocation) ->
+
inv.name = "Email/query") query_response.method_responses in
+
let args = query_method.arguments in
+
match Ezjsonm.find_opt args ["ids"] with
+
| Some (`A ids) ->
+
let email_ids = List.map (function
+
| `String id -> id
+
| _ -> raise (Invalid_argument "Email ID is not a string")
+
) ids in
+
+
(* If we have IDs, fetch the actual email objects *)
+
if List.length email_ids > 0 then
+
let get_request = {
+
using = ["urn:ietf:params:jmap:core"; Types.capability_mail];
+
method_calls = [
+
{
+
name = "Email/get";
+
arguments = `O [
+
("accountId", `String account_id);
+
("ids", `A (List.map (fun id -> `String id) email_ids));
+
];
+
method_call_id = "g1";
+
}
+
];
+
created_ids = None;
+
} in
+
+
let* get_result = make_request conn.config get_request in
+
match get_result with
+
| Ok get_response ->
+
(try
+
let get_method = List.find (fun (inv : Ezjsonm.value Jmap.Types.invocation) ->
+
inv.name = "Email/get") get_response.method_responses in
+
let args = get_method.arguments in
+
match Ezjsonm.find_opt args ["list"] with
+
| Some (`A email_list) ->
+
let parse_results = List.map email_of_json email_list in
+
let (successes, failures) = List.partition Result.is_ok parse_results in
+
if List.length failures > 0 then
+
Lwt.return (Error (Parse_error "Failed to parse some emails"))
+
else
+
Lwt.return (Ok (List.map Result.get_ok successes))
+
| _ -> Lwt.return (Error (Parse_error "Email list not found in response"))
+
with
+
| Not_found -> Lwt.return (Error (Parse_error "Email/get method response not found"))
+
| e -> Lwt.return (Error (Parse_error (Printexc.to_string e))))
+
| Error e -> Lwt.return (Error e)
+
else
+
(* No emails in mailbox *)
+
Lwt.return (Ok [])
+
+
| _ -> Lwt.return (Error (Parse_error "Email IDs not found in query response"))
+
with
+
| Not_found -> Lwt.return (Error (Parse_error "Email/query method response not found"))
+
| Invalid_argument msg -> Lwt.return (Error (Parse_error msg))
+
| e -> Lwt.return (Error (Parse_error (Printexc.to_string e))))
+
| Error e -> Lwt.return (Error e)
+
+
(** Get a single email message by ID
+
@param conn The JMAP connection
+
@param account_id The account ID
+
@param email_id The email ID to retrieve
+
@return The email message if found
+
+
TODO:claude *)
+
let get_email conn ~account_id ~email_id =
+
let request = {
+
using = ["urn:ietf:params:jmap:core"; Types.capability_mail];
+
method_calls = [
+
{
+
name = "Email/get";
+
arguments = `O [
+
("accountId", `String account_id);
+
("ids", `A [`String email_id]);
+
];
+
method_call_id = "m1";
+
}
+
];
+
created_ids = None;
+
} in
+
+
let* response_result = make_request conn.config request in
+
match response_result with
+
| Ok response ->
+
let result =
+
try
+
let method_response = List.find (fun (inv : Ezjsonm.value Jmap.Types.invocation) ->
+
inv.name = "Email/get") response.method_responses in
+
let args = method_response.arguments in
+
match Ezjsonm.find_opt args ["list"] with
+
| Some (`A [email]) -> email_of_json email
+
| Some (`A []) -> Error (Parse_error ("Email not found: " ^ email_id))
+
| _ -> Error (Parse_error "Expected single email in response")
+
with
+
| Not_found -> Error (Parse_error "Email/get method response not found")
+
| e -> Error (Parse_error (Printexc.to_string e))
+
in
+
Lwt.return result
+
| Error e -> Lwt.return (Error e)
+72 -7
lib/jmap_mail.mli
···
(** {1 API functions} *)
-
(** TODO:claude - Need to implement API functions for interacting with the
-
mail-specific JMAP server endpoints. These would use the Jmap.Api module
-
to make HTTP requests and parse responses.
+
(** Authentication credentials for a JMAP server *)
+
type credentials = {
+
username: string;
+
password: string;
+
}
+
+
(** Connection to a JMAP mail server *)
+
type connection = {
+
session: Jmap.Types.session;
+
config: Jmap.Api.config;
+
}
+
+
(** Login to a JMAP server and establish a connection
+
@param uri The URI of the JMAP server
+
@param credentials Authentication credentials
+
@return A connection object if successful
+
+
TODO:claude *)
+
val login :
+
uri:string ->
+
credentials:credentials ->
+
(connection, Jmap.Api.error) result Lwt.t
+
+
(** Get all mailboxes for an account
+
@param conn The JMAP connection
+
@param account_id The account ID to get mailboxes for
+
@return A list of mailboxes if successful
+
+
TODO:claude *)
+
val get_mailboxes :
+
connection ->
+
account_id:Jmap.Types.id ->
+
(Types.mailbox list, Jmap.Api.error) result Lwt.t
+
+
(** Get a specific mailbox by ID
+
@param conn The JMAP connection
+
@param account_id The account ID
+
@param mailbox_id The mailbox ID to retrieve
+
@return The mailbox if found
-
The interface would include functions like:
+
TODO:claude *)
+
val get_mailbox :
+
connection ->
+
account_id:Jmap.Types.id ->
+
mailbox_id:Jmap.Types.id ->
+
(Types.mailbox, Jmap.Api.error) result Lwt.t
+
+
(** Get messages in a mailbox
+
@param conn The JMAP connection
+
@param account_id The account ID
+
@param mailbox_id The mailbox ID to get messages from
+
@param limit Optional limit on number of messages to return
+
@return The list of email messages if successful
-
val get_mailboxes : Jmap.Api.session -> ?ids:id list -> ?properties:string list -> account_id:id -> (mailbox_get_response, error) result Lwt.t
+
TODO:claude *)
+
val get_messages_in_mailbox :
+
connection ->
+
account_id:Jmap.Types.id ->
+
mailbox_id:Jmap.Types.id ->
+
?limit:int ->
+
unit ->
+
(Types.email list, Jmap.Api.error) result Lwt.t
+
+
(** Get a single email message by ID
+
@param conn The JMAP connection
+
@param account_id The account ID
+
@param email_id The email ID to retrieve
+
@return The email message if found
-
And similarly for all other API operations.
-
*)
+
TODO:claude *)
+
val get_email :
+
connection ->
+
account_id:Jmap.Types.id ->
+
email_id:Jmap.Types.id ->
+
(Types.email, Jmap.Api.error) result Lwt.t