(** Implementation of the JMAP Mail extension, as defined in RFC8621 *) (** Module for managing JMAP Mail-specific capability URIs *) module Capability = struct (** Mail capability URI *) let mail_uri = "urn:ietf:params:jmap:mail" (** Submission capability URI *) let submission_uri = "urn:ietf:params:jmap:submission" (** Vacation response capability URI *) let vacation_response_uri = "urn:ietf:params:jmap:vacationresponse" (** All mail extension capability types *) type t = | Mail (** Mail capability *) | Submission (** Submission capability *) | VacationResponse (** Vacation response capability *) | Extension of string (** Custom extension *) (** Convert capability to URI string *) let to_string = function | Mail -> mail_uri | Submission -> submission_uri | VacationResponse -> vacation_response_uri | Extension s -> s (** Parse a string to a capability *) let of_string s = if s = mail_uri then Mail else if s = submission_uri then Submission else if s = vacation_response_uri then VacationResponse else Extension s (** Check if a capability is a standard mail capability *) let is_standard = function | Mail | Submission | VacationResponse -> true | Extension _ -> false (** Check if a capability string is a standard mail capability *) let is_standard_string s = s = mail_uri || s = submission_uri || s = vacation_response_uri (** Create a list of capability strings *) let strings_of_capabilities capabilities = List.map to_string capabilities end module Types = struct open Jmap.Types (** {1 Mail capabilities} *) (** Capability URI for JMAP Mail*) let capability_mail = Capability.mail_uri (** Capability URI for JMAP Submission *) let capability_submission = Capability.submission_uri (** Capability URI for JMAP Vacation Response *) let capability_vacation_response = Capability.vacation_response_uri (** {1:mailbox Mailbox objects} *) (** A role for a mailbox. See RFC8621 Section 2. *) type mailbox_role = | All (** All mail *) | Archive (** Archived mail *) | Drafts (** Draft messages *) | Flagged (** Starred/flagged mail *) | Important (** Important mail *) | Inbox (** Inbox *) | Junk (** Spam/Junk mail *) | Sent (** Sent mail *) | Trash (** Deleted/Trash mail *) | Unknown of string (** Server-specific roles *) (** A mailbox (folder) in a mail account. See RFC8621 Section 2. *) type mailbox = { id : id; name : string; parent_id : id option; role : mailbox_role option; sort_order : unsigned_int; total_emails : unsigned_int; unread_emails : unsigned_int; total_threads : unsigned_int; unread_threads : unsigned_int; is_subscribed : bool; my_rights : mailbox_rights; } (** Rights for a mailbox. See RFC8621 Section 2. *) and mailbox_rights = { may_read_items : bool; may_add_items : bool; may_remove_items : bool; may_set_seen : bool; may_set_keywords : bool; may_create_child : bool; may_rename : bool; may_delete : bool; may_submit : bool; } (** Filter condition for mailbox queries. See RFC8621 Section 2.3. *) type mailbox_filter_condition = { parent_id : id option; name : string option; role : string option; has_any_role : bool option; is_subscribed : bool option; } type mailbox_query_filter = [ | `And of mailbox_query_filter list | `Or of mailbox_query_filter list | `Not of mailbox_query_filter | `Condition of mailbox_filter_condition ] (** Mailbox/get request arguments. See RFC8621 Section 2.1. *) type mailbox_get_arguments = { account_id : id; ids : id list option; properties : string list option; } (** Mailbox/get response. See RFC8621 Section 2.1. *) type mailbox_get_response = { account_id : id; state : string; list : mailbox list; not_found : id list; } (** Mailbox/changes request arguments. See RFC8621 Section 2.2. *) type mailbox_changes_arguments = { account_id : id; since_state : string; max_changes : unsigned_int option; } (** Mailbox/changes response. See RFC8621 Section 2.2. *) type mailbox_changes_response = { account_id : id; old_state : string; new_state : string; has_more_changes : bool; created : id list; updated : id list; destroyed : id list; } (** Mailbox/query request arguments. See RFC8621 Section 2.3. *) type mailbox_query_arguments = { account_id : id; filter : mailbox_query_filter option; sort : [ `name | `role | `sort_order ] list option; limit : unsigned_int option; } (** Mailbox/query response. See RFC8621 Section 2.3. *) type mailbox_query_response = { account_id : id; query_state : string; can_calculate_changes : bool; position : unsigned_int; ids : id list; total : unsigned_int option; } (** Mailbox/queryChanges request arguments. See RFC8621 Section 2.4. *) type mailbox_query_changes_arguments = { account_id : id; filter : mailbox_query_filter option; sort : [ `name | `role | `sort_order ] list option; since_query_state : string; max_changes : unsigned_int option; up_to_id : id option; } (** Mailbox/queryChanges response. See RFC8621 Section 2.4. *) type mailbox_query_changes_response = { account_id : id; old_query_state : string; new_query_state : string; total : unsigned_int option; removed : id list; added : mailbox_query_changes_added list; } and mailbox_query_changes_added = { id : id; index : unsigned_int; } (** Mailbox/set request arguments. See RFC8621 Section 2.5. *) type mailbox_set_arguments = { account_id : id; if_in_state : string option; create : (id * mailbox_creation) list option; update : (id * mailbox_update) list option; destroy : id list option; } and mailbox_creation = { name : string; parent_id : id option; role : string option; sort_order : unsigned_int option; is_subscribed : bool option; } and mailbox_update = { name : string option; parent_id : id option; role : string option; sort_order : unsigned_int option; is_subscribed : bool option; } (** Mailbox/set response. See RFC8621 Section 2.5. *) type mailbox_set_response = { account_id : id; old_state : string option; new_state : string; created : (id * mailbox) list option; updated : id list option; destroyed : id list option; not_created : (id * set_error) list option; not_updated : (id * set_error) list option; not_destroyed : (id * set_error) list option; } (** {1:thread Thread objects} *) (** A thread in a mail account. See RFC8621 Section 3. *) type thread = { id : id; email_ids : id list; } (** Thread/get request arguments. See RFC8621 Section 3.1. *) type thread_get_arguments = { account_id : id; ids : id list option; properties : string list option; } (** Thread/get response. See RFC8621 Section 3.1. *) type thread_get_response = { account_id : id; state : string; list : thread list; not_found : id list; } (** Thread/changes request arguments. See RFC8621 Section 3.2. *) type thread_changes_arguments = { account_id : id; since_state : string; max_changes : unsigned_int option; } (** Thread/changes response. See RFC8621 Section 3.2. *) type thread_changes_response = { account_id : id; old_state : string; new_state : string; has_more_changes : bool; created : id list; updated : id list; destroyed : id list; } (** {1:email Email objects} *) (** Addressing (mailbox) information. See RFC8621 Section 4.1.1. *) type email_address = { name : string option; email : string; parameters : (string * string) list; } (** Message header field. See RFC8621 Section 4.1.2. *) type header = { name : string; value : string; } (** Email keyword (flag). See RFC8621 Section 4.3. *) type keyword = | Flagged | Answered | Draft | Forwarded | Phishing | Junk | NotJunk | Seen | Unread | Custom of string (** Email message. See RFC8621 Section 4. *) type email = { id : id; blob_id : id; thread_id : id; mailbox_ids : (id * bool) list; keywords : (keyword * bool) list; size : unsigned_int; received_at : utc_date; message_id : string list; in_reply_to : string list option; references : string list option; sender : email_address list option; from : email_address list option; to_ : email_address list option; cc : email_address list option; bcc : email_address list option; reply_to : email_address list option; subject : string option; sent_at : utc_date option; has_attachment : bool option; preview : string option; body_values : (string * string) list option; text_body : email_body_part list option; html_body : email_body_part list option; attachments : email_body_part list option; headers : header list option; } (** Email body part. See RFC8621 Section 4.1.4. *) and email_body_part = { part_id : string option; blob_id : id option; size : unsigned_int option; headers : header list option; name : string option; type_ : string option; charset : string option; disposition : string option; cid : string option; language : string list option; location : string option; sub_parts : email_body_part list option; header_parameter_name : string option; header_parameter_value : string option; } (** Email query filter condition. See RFC8621 Section 4.4. *) type email_filter_condition = { in_mailbox : id option; in_mailbox_other_than : id list option; min_size : unsigned_int option; max_size : unsigned_int option; before : utc_date option; after : utc_date option; header : (string * string) option; from : string option; to_ : string option; cc : string option; bcc : string option; subject : string option; body : string option; has_keyword : string option; not_keyword : string option; has_attachment : bool option; text : string option; } type email_query_filter = [ | `And of email_query_filter list | `Or of email_query_filter list | `Not of email_query_filter | `Condition of email_filter_condition ] (** Email/get request arguments. See RFC8621 Section 4.5. *) type email_get_arguments = { account_id : id; ids : id list option; properties : string list option; body_properties : string list option; fetch_text_body_values : bool option; fetch_html_body_values : bool option; fetch_all_body_values : bool option; max_body_value_bytes : unsigned_int option; } (** Email/get response. See RFC8621 Section 4.5. *) type email_get_response = { account_id : id; state : string; list : email list; not_found : id list; } (** Email/changes request arguments. See RFC8621 Section 4.6. *) type email_changes_arguments = { account_id : id; since_state : string; max_changes : unsigned_int option; } (** Email/changes response. See RFC8621 Section 4.6. *) type email_changes_response = { account_id : id; old_state : string; new_state : string; has_more_changes : bool; created : id list; updated : id list; destroyed : id list; } (** Email/query request arguments. See RFC8621 Section 4.4. *) type email_query_arguments = { account_id : id; filter : email_query_filter option; sort : comparator list option; collapse_threads : bool option; position : unsigned_int option; anchor : id option; anchor_offset : int_t option; limit : unsigned_int option; calculate_total : bool option; } (** Email/query response. See RFC8621 Section 4.4. *) type email_query_response = { account_id : id; query_state : string; can_calculate_changes : bool; position : unsigned_int; ids : id list; total : unsigned_int option; thread_ids : id list option; } (** Email/queryChanges request arguments. See RFC8621 Section 4.7. *) type email_query_changes_arguments = { account_id : id; filter : email_query_filter option; sort : comparator list option; collapse_threads : bool option; since_query_state : string; max_changes : unsigned_int option; up_to_id : id option; } (** Email/queryChanges response. See RFC8621 Section 4.7. *) type email_query_changes_response = { account_id : id; old_query_state : string; new_query_state : string; total : unsigned_int option; removed : id list; added : email_query_changes_added list; } and email_query_changes_added = { id : id; index : unsigned_int; } (** Email/set request arguments. See RFC8621 Section 4.8. *) type email_set_arguments = { account_id : id; if_in_state : string option; create : (id * email_creation) list option; update : (id * email_update) list option; destroy : id list option; } and email_creation = { mailbox_ids : (id * bool) list; keywords : (keyword * bool) list option; received_at : utc_date option; message_id : string list option; in_reply_to : string list option; references : string list option; sender : email_address list option; from : email_address list option; to_ : email_address list option; cc : email_address list option; bcc : email_address list option; reply_to : email_address list option; subject : string option; body_values : (string * string) list option; text_body : email_body_part list option; html_body : email_body_part list option; attachments : email_body_part list option; headers : header list option; } and email_update = { keywords : (keyword * bool) list option; mailbox_ids : (id * bool) list option; } (** Email/set response. See RFC8621 Section 4.8. *) type email_set_response = { account_id : id; old_state : string option; new_state : string; created : (id * email) list option; updated : id list option; destroyed : id list option; not_created : (id * set_error) list option; not_updated : (id * set_error) list option; not_destroyed : (id * set_error) list option; } (** Email/copy request arguments. See RFC8621 Section 4.9. *) type email_copy_arguments = { from_account_id : id; account_id : id; create : (id * email_creation) list; on_success_destroy_original : bool option; } (** Email/copy response. See RFC8621 Section 4.9. *) type email_copy_response = { from_account_id : id; account_id : id; created : (id * email) list option; not_created : (id * set_error) list option; } (** Email/import request arguments. See RFC8621 Section 4.10. *) type email_import_arguments = { account_id : id; emails : (id * email_import) list; } and email_import = { blob_id : id; mailbox_ids : (id * bool) list; keywords : (keyword * bool) list option; received_at : utc_date option; } (** Email/import response. See RFC8621 Section 4.10. *) type email_import_response = { account_id : id; created : (id * email) list option; not_created : (id * set_error) list option; } (** {1:search_snippet Search snippets} *) (** SearchSnippet/get request arguments. See RFC8621 Section 4.11. *) type search_snippet_get_arguments = { account_id : id; email_ids : id list; filter : email_filter_condition; } (** SearchSnippet/get response. See RFC8621 Section 4.11. *) type search_snippet_get_response = { account_id : id; list : (id * search_snippet) list; not_found : id list; } and search_snippet = { subject : string option; preview : string option; } (** {1:submission EmailSubmission objects} *) (** EmailSubmission address. See RFC8621 Section 5.1. *) type submission_address = { email : string; parameters : (string * string) list option; } (** Email submission object. See RFC8621 Section 5.1. *) type email_submission = { id : id; identity_id : id; email_id : id; thread_id : id; envelope : envelope option; send_at : utc_date option; undo_status : [ | `pending | `final | `canceled ] option; delivery_status : (string * submission_status) list option; dsn_blob_ids : (string * id) list option; mdn_blob_ids : (string * id) list option; } (** Envelope for mail submission. See RFC8621 Section 5.1. *) and envelope = { mail_from : submission_address; rcpt_to : submission_address list; } (** Delivery status for submitted email. See RFC8621 Section 5.1. *) and submission_status = { smtp_reply : string; delivered : string option; } (** EmailSubmission/get request arguments. See RFC8621 Section 5.3. *) type email_submission_get_arguments = { account_id : id; ids : id list option; properties : string list option; } (** EmailSubmission/get response. See RFC8621 Section 5.3. *) type email_submission_get_response = { account_id : id; state : string; list : email_submission list; not_found : id list; } (** EmailSubmission/changes request arguments. See RFC8621 Section 5.4. *) type email_submission_changes_arguments = { account_id : id; since_state : string; max_changes : unsigned_int option; } (** EmailSubmission/changes response. See RFC8621 Section 5.4. *) type email_submission_changes_response = { account_id : id; old_state : string; new_state : string; has_more_changes : bool; created : id list; updated : id list; destroyed : id list; } (** EmailSubmission/query filter condition. See RFC8621 Section 5.5. *) type email_submission_filter_condition = { identity_id : id option; email_id : id option; thread_id : id option; before : utc_date option; after : utc_date option; subject : string option; } type email_submission_query_filter = [ | `And of email_submission_query_filter list | `Or of email_submission_query_filter list | `Not of email_submission_query_filter | `Condition of email_submission_filter_condition ] (** EmailSubmission/query request arguments. See RFC8621 Section 5.5. *) type email_submission_query_arguments = { account_id : id; filter : email_submission_query_filter option; sort : comparator list option; position : unsigned_int option; anchor : id option; anchor_offset : int_t option; limit : unsigned_int option; calculate_total : bool option; } (** EmailSubmission/query response. See RFC8621 Section 5.5. *) type email_submission_query_response = { account_id : id; query_state : string; can_calculate_changes : bool; position : unsigned_int; ids : id list; total : unsigned_int option; } (** EmailSubmission/set request arguments. See RFC8621 Section 5.6. *) type email_submission_set_arguments = { account_id : id; if_in_state : string option; create : (id * email_submission_creation) list option; update : (id * email_submission_update) list option; destroy : id list option; on_success_update_email : (id * email_update) list option; } and email_submission_creation = { email_id : id; identity_id : id; envelope : envelope option; send_at : utc_date option; } and email_submission_update = { email_id : id option; identity_id : id option; envelope : envelope option; undo_status : [`canceled] option; } (** EmailSubmission/set response. See RFC8621 Section 5.6. *) type email_submission_set_response = { account_id : id; old_state : string option; new_state : string; created : (id * email_submission) list option; updated : id list option; destroyed : id list option; not_created : (id * set_error) list option; not_updated : (id * set_error) list option; not_destroyed : (id * set_error) list option; } (** {1:identity Identity objects} *) (** Identity for sending mail. See RFC8621 Section 6. *) type identity = { id : id; name : string; email : string; reply_to : email_address list option; bcc : email_address list option; text_signature : string option; html_signature : string option; may_delete : bool; } (** Identity/get request arguments. See RFC8621 Section 6.1. *) type identity_get_arguments = { account_id : id; ids : id list option; properties : string list option; } (** Identity/get response. See RFC8621 Section 6.1. *) type identity_get_response = { account_id : id; state : string; list : identity list; not_found : id list; } (** Identity/changes request arguments. See RFC8621 Section 6.2. *) type identity_changes_arguments = { account_id : id; since_state : string; max_changes : unsigned_int option; } (** Identity/changes response. See RFC8621 Section 6.2. *) type identity_changes_response = { account_id : id; old_state : string; new_state : string; has_more_changes : bool; created : id list; updated : id list; destroyed : id list; } (** Identity/set request arguments. See RFC8621 Section 6.3. *) type identity_set_arguments = { account_id : id; if_in_state : string option; create : (id * identity_creation) list option; update : (id * identity_update) list option; destroy : id list option; } and identity_creation = { name : string; email : string; reply_to : email_address list option; bcc : email_address list option; text_signature : string option; html_signature : string option; } and identity_update = { name : string option; email : string option; reply_to : email_address list option; bcc : email_address list option; text_signature : string option; html_signature : string option; } (** Identity/set response. See RFC8621 Section 6.3. *) type identity_set_response = { account_id : id; old_state : string option; new_state : string; created : (id * identity) list option; updated : id list option; destroyed : id list option; not_created : (id * set_error) list option; not_updated : (id * set_error) list option; not_destroyed : (id * set_error) list option; } (** {1:vacation_response VacationResponse objects} *) (** Vacation auto-reply setting. See RFC8621 Section 7. *) type vacation_response = { id : id; is_enabled : bool; from_date : utc_date option; to_date : utc_date option; subject : string option; text_body : string option; html_body : string option; } (** VacationResponse/get request arguments. See RFC8621 Section 7.2. *) type vacation_response_get_arguments = { account_id : id; ids : id list option; properties : string list option; } (** VacationResponse/get response. See RFC8621 Section 7.2. *) type vacation_response_get_response = { account_id : id; state : string; list : vacation_response list; not_found : id list; } (** VacationResponse/set request arguments. See RFC8621 Section 7.3. *) type vacation_response_set_arguments = { account_id : id; if_in_state : string option; update : (id * vacation_response_update) list; } and vacation_response_update = { is_enabled : bool option; from_date : utc_date option; to_date : utc_date option; subject : string option; text_body : string option; html_body : string option; } (** VacationResponse/set response. See RFC8621 Section 7.3. *) type vacation_response_set_response = { account_id : id; old_state : string option; new_state : string; updated : id list option; not_updated : (id * set_error) list option; } (** {1:message_flags Message Flags and Mailbox Attributes} *) (** Flag color defined by the combination of MailFlagBit0, MailFlagBit1, and MailFlagBit2 keywords *) type flag_color = | Red (** Bit pattern 000 *) | Orange (** Bit pattern 100 *) | Yellow (** Bit pattern 010 *) | Green (** Bit pattern 111 *) | Blue (** Bit pattern 001 *) | Purple (** Bit pattern 101 *) | Gray (** Bit pattern 011 *) (** Standard message keywords as defined in draft-ietf-mailmaint-messageflag-mailboxattribute-02 *) type message_keyword = | Notify (** Indicate a notification should be shown for this message *) | Muted (** User is not interested in future replies to this thread *) | Followed (** User is particularly interested in future replies to this thread *) | Memo (** Message is a note-to-self about another message in the same thread *) | HasMemo (** Message has an associated memo with the $memo keyword *) | HasAttachment (** Message has an attachment *) | HasNoAttachment (** Message does not have an attachment *) | AutoSent (** Message was sent automatically as a response due to a user rule *) | Unsubscribed (** User has unsubscribed from the thread this message is in *) | CanUnsubscribe (** Message has an RFC8058-compliant List-Unsubscribe header *) | Imported (** Message was imported from another mailbox *) | IsTrusted (** Server has verified authenticity of the from name and email *) | MaskedEmail (** Message was received via an alias created for an individual sender *) | New (** Message should be made more prominent due to a recent action *) | MailFlagBit0 (** Bit 0 of the 3-bit flag color pattern *) | MailFlagBit1 (** Bit 1 of the 3-bit flag color pattern *) | MailFlagBit2 (** Bit 2 of the 3-bit flag color pattern *) | OtherKeyword of string (** Other non-standard keywords *) (** Special mailbox attribute names as defined in draft-ietf-mailmaint-messageflag-mailboxattribute-02 *) type mailbox_attribute = | Snoozed (** Mailbox containing messages that have been snoozed *) | Scheduled (** Mailbox containing messages scheduled to be sent later *) | Memos (** Mailbox containing messages with the $memo keyword *) | OtherAttribute of string (** Other non-standard mailbox attributes *) (** Functions for working with flag colors based on the specification in draft-ietf-mailmaint-messageflag-mailboxattribute-02, section 3.1. *) (** Convert bit pattern to flag color *) let flag_color_of_bits bit0 bit1 bit2 = match (bit0, bit1, bit2) with | (false, false, false) -> Red (* 000 *) | (true, false, false) -> Orange (* 100 *) | (false, true, false) -> Yellow (* 010 *) | (true, true, true) -> Green (* 111 *) | (false, false, true) -> Blue (* 001 *) | (true, false, true) -> Purple (* 101 *) | (false, true, true) -> Gray (* 011 *) | (true, true, false) -> Green (* 110 - not in spec, defaulting to green *) (** Get bits for a flag color *) let bits_of_flag_color = function | Red -> (false, false, false) | Orange -> (true, false, false) | Yellow -> (false, true, false) | Green -> (true, true, true) | Blue -> (false, false, true) | Purple -> (true, false, true) | Gray -> (false, true, true) (** Check if a keyword list contains a flag color *) let has_flag_color keywords = let has_bit0 = List.exists (function | (Custom s, true) when s = "$MailFlagBit0" -> true | _ -> false ) keywords in let has_bit1 = List.exists (function | (Custom s, true) when s = "$MailFlagBit1" -> true | _ -> false ) keywords in let has_bit2 = List.exists (function | (Custom s, true) when s = "$MailFlagBit2" -> true | _ -> false ) keywords in has_bit0 || has_bit1 || has_bit2 (** Extract flag color from keywords if present *) let get_flag_color keywords = (* First check if the message has the \Flagged system flag *) let is_flagged = List.exists (function | (Flagged, true) -> true | _ -> false ) keywords in if not is_flagged then None else (* Get values of each bit flag *) let bit0 = List.exists (function | (Custom s, true) when s = "$MailFlagBit0" -> true | _ -> false ) keywords in let bit1 = List.exists (function | (Custom s, true) when s = "$MailFlagBit1" -> true | _ -> false ) keywords in let bit2 = List.exists (function | (Custom s, true) when s = "$MailFlagBit2" -> true | _ -> false ) keywords in Some (flag_color_of_bits bit0 bit1 bit2) (** Convert a message keyword to its string representation *) let string_of_message_keyword = function | Notify -> "$notify" | Muted -> "$muted" | Followed -> "$followed" | Memo -> "$memo" | HasMemo -> "$hasmemo" | HasAttachment -> "$hasattachment" | HasNoAttachment -> "$hasnoattachment" | AutoSent -> "$autosent" | Unsubscribed -> "$unsubscribed" | CanUnsubscribe -> "$canunsubscribe" | Imported -> "$imported" | IsTrusted -> "$istrusted" | MaskedEmail -> "$maskedemail" | New -> "$new" | MailFlagBit0 -> "$MailFlagBit0" | MailFlagBit1 -> "$MailFlagBit1" | MailFlagBit2 -> "$MailFlagBit2" | OtherKeyword s -> s (** Parse a string into a message keyword *) let message_keyword_of_string = function | "$notify" -> Notify | "$muted" -> Muted | "$followed" -> Followed | "$memo" -> Memo | "$hasmemo" -> HasMemo | "$hasattachment" -> HasAttachment | "$hasnoattachment" -> HasNoAttachment | "$autosent" -> AutoSent | "$unsubscribed" -> Unsubscribed | "$canunsubscribe" -> CanUnsubscribe | "$imported" -> Imported | "$istrusted" -> IsTrusted | "$maskedemail" -> MaskedEmail | "$new" -> New | "$MailFlagBit0" -> MailFlagBit0 | "$MailFlagBit1" -> MailFlagBit1 | "$MailFlagBit2" -> MailFlagBit2 | s -> OtherKeyword s (** Convert a mailbox attribute to its string representation *) let string_of_mailbox_attribute = function | Snoozed -> "Snoozed" | Scheduled -> "Scheduled" | Memos -> "Memos" | OtherAttribute s -> s (** Parse a string into a mailbox attribute *) let mailbox_attribute_of_string = function | "Snoozed" -> Snoozed | "Scheduled" -> Scheduled | "Memos" -> Memos | s -> OtherAttribute s (** Get a human-readable representation of a flag color *) let human_readable_flag_color = function | Red -> "Red" | Orange -> "Orange" | Yellow -> "Yellow" | Green -> "Green" | Blue -> "Blue" | Purple -> "Purple" | Gray -> "Gray" (** Get a human-readable representation of a message keyword *) let human_readable_message_keyword = function | Notify -> "Notify" | Muted -> "Muted" | Followed -> "Followed" | Memo -> "Memo" | HasMemo -> "Has Memo" | HasAttachment -> "Has Attachment" | HasNoAttachment -> "No Attachment" | AutoSent -> "Auto Sent" | Unsubscribed -> "Unsubscribed" | CanUnsubscribe -> "Can Unsubscribe" | Imported -> "Imported" | IsTrusted -> "Trusted" | MaskedEmail -> "Masked Email" | New -> "New" | MailFlagBit0 | MailFlagBit1 | MailFlagBit2 -> "Flag Bit" | OtherKeyword s -> s (** Format email keywords into a human-readable string representation *) let format_email_keywords keywords = (* Get flag color if present *) let color_str = match get_flag_color keywords with | Some color -> human_readable_flag_color color | None -> "" in (* Get standard JMAP keywords *) let standard_keywords = List.filter_map (fun (kw, active) -> if not active then None else match kw with | Flagged -> Some "Flagged" | Answered -> Some "Answered" | Draft -> Some "Draft" | Forwarded -> Some "Forwarded" | Phishing -> Some "Phishing" | Junk -> Some "Junk" | NotJunk -> Some "Not Junk" | Seen -> Some "Seen" | Unread -> Some "Unread" | _ -> None ) keywords in (* Get message keywords *) let message_keywords = List.filter_map (fun (kw, active) -> if not active then None else match kw with | Custom s -> (* Try to parse as message keyword *) let message_kw = message_keyword_of_string s in (match message_kw with | OtherKeyword _ -> None | MailFlagBit0 | MailFlagBit1 | MailFlagBit2 -> None | kw -> Some (human_readable_message_keyword kw)) | _ -> None ) keywords in (* Combine all human-readable labels *) let all_parts = (if color_str <> "" then [color_str] else []) @ standard_keywords @ message_keywords in String.concat ", " all_parts end (** {1 JSON serialization} *) module Json = struct open Types (** {2 Helper functions for serialization} *) let string_of_mailbox_role = function | All -> "all" | Archive -> "archive" | Drafts -> "drafts" | Flagged -> "flagged" | Important -> "important" | Inbox -> "inbox" | Junk -> "junk" | Sent -> "sent" | Trash -> "trash" | Unknown s -> s let mailbox_role_of_string = function | "all" -> All | "archive" -> Archive | "drafts" -> Drafts | "flagged" -> Flagged | "important" -> Important | "inbox" -> Inbox | "junk" -> Junk | "sent" -> Sent | "trash" -> Trash | s -> Unknown s let string_of_keyword = function | Flagged -> "$flagged" | Answered -> "$answered" | Draft -> "$draft" | Forwarded -> "$forwarded" | Phishing -> "$phishing" | Junk -> "$junk" | NotJunk -> "$notjunk" | Seen -> "$seen" | Unread -> "$unread" | Custom s -> s let keyword_of_string = function | "$flagged" -> Flagged | "$answered" -> Answered | "$draft" -> Draft | "$forwarded" -> Forwarded | "$phishing" -> Phishing | "$junk" -> Junk | "$notjunk" -> NotJunk | "$seen" -> Seen | "$unread" -> Unread | s -> Custom s (** {2 Mailbox serialization} *) (** TODO:claude - Need to implement all JSON serialization functions for each type we've defined. This would be a substantial amount of code and likely require additional understanding of the ezjsonm API. For a full implementation, we would need functions to convert between OCaml types and JSON for each of: - mailbox, mailbox_rights, mailbox query/update operations - thread operations - email, email_address, header, email_body_part - email query/update operations - submission operations - identity operations - vacation response operations *) end (** {1 API functions} *) 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 (* Handle parentId which can be null *) let parent_id = match find_opt json ["parentId"] with | Some (`Null) -> None | Some (`String s) -> Some s | None -> None | _ -> None in (* Handle role which might be null *) let role = match find_opt json ["role"] with | Some (`Null) -> None | Some (`String s) -> Some (Json.mailbox_role_of_string s) | None -> None | _ -> None 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 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 let result = { Types.id; name; parent_id; role; sort_order; total_emails; unread_emails; total_threads; unread_threads; is_subscribed; my_rights; } in Ok (result) 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 (* Handle messageId which might be an array or missing *) let message_id = match find_opt json ["messageId"] with | Some (`A ids) -> List.map (fun id -> match id with | `String s -> s | _ -> raise (Invalid_argument "messageId item is not a string") ) ids | Some (`String s) -> [s] (* Handle single string case *) | None -> [] (* Handle missing case *) | _ -> raise (Invalid_argument "messageId has unexpected type") 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 = match find_opt addr_json ["name"] with | Some (`String s) -> Some s | Some (`Null) -> None | None -> None | _ -> None 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) -> match v with | `String s -> (k, s) | _ -> (k, "") ) items | _ -> [] in { Types.name; email; parameters } ) items) | _ -> None in (* Handle optional string arrays with null handling *) let parse_string_array_opt field_name = match find_opt json [field_name] with | Some (`A ids) -> Some (List.filter_map (function | `String s -> Some s | _ -> None ) ids) | Some (`Null) -> None | None -> None | _ -> None in let in_reply_to = parse_string_array_opt "inReplyTo" in let references = parse_string_array_opt "references" 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 (* Handle optional string fields with null handling *) let parse_string_opt field_name = match find_opt json [field_name] with | Some (`String s) -> Some s | Some (`Null) -> None | None -> None | _ -> None in let subject = parse_string_opt "subject" in let sent_at = parse_string_opt "sentAt" in (* Handle optional boolean fields with null handling *) let parse_bool_opt field_name = match find_opt json [field_name] with | Some (`Bool b) -> Some b | Some (`Null) -> None | None -> None | _ -> None in let has_attachment = parse_bool_opt "hasAttachment" in let preview = parse_string_opt "preview" in (* TODO 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) (** Login to a JMAP server using an API token @param uri The URI of the JMAP server @param api_token The API token for authentication @return A connection object if successful TODO:claude *) let login_with_token ~uri ~api_token = let* session_result = get_session (Uri.of_string uri) ~api_token () in match session_result with | Ok session -> let api_uri = Uri.of_string session.api_url in let config = { api_uri; username = ""; (* Empty username indicates we're using token auth *) authentication_token = api_token; } 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 = [ Jmap.Capability.to_string Jmap.Capability.Core; Capability.to_string 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 = [ Jmap.Capability.to_string Jmap.Capability.Core; Capability.to_string 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 = [ Jmap.Capability.to_string Jmap.Capability.Core; Capability.to_string 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 = [ Jmap.Capability.to_string Jmap.Capability.Core; Capability.to_string 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 = [ Jmap.Capability.to_string Jmap.Capability.Core; Capability.to_string 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) (** Helper functions for working with message flags and mailbox attributes *) (** Check if an email has a specific message keyword @param email The email to check @param keyword The message keyword to look for @return true if the email has the keyword, false otherwise TODO:claude *) let has_message_keyword (email:Types.email) keyword = let open Types in let keyword_string = string_of_message_keyword keyword in List.exists (function | (Custom s, true) when s = keyword_string -> true | _ -> false ) email.keywords (** Add a message keyword to an email @param conn The JMAP connection @param account_id The account ID @param email_id The email ID @param keyword The message keyword to add @return Success or error TODO:claude *) let add_message_keyword conn ~account_id ~email_id ~keyword = let keyword_string = Types.string_of_message_keyword keyword in let request = { using = [ Jmap.Capability.to_string Jmap.Capability.Core; Capability.to_string Capability.Mail ]; method_calls = [ { name = "Email/set"; arguments = `O [ ("accountId", `String account_id); ("update", `O [ (email_id, `O [ ("keywords", `O [ (keyword_string, `Bool true) ]) ]) ]); ]; 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/set") response.method_responses in let args = method_response.arguments in match Ezjsonm.find_opt args ["updated"] with | Some (`A _ids) -> Ok () | _ -> match Ezjsonm.find_opt args ["notUpdated"] with | Some (`O _errors) -> Error (Parse_error ("Failed to update email: " ^ email_id)) | _ -> Error (Parse_error "Unexpected response format") with | Not_found -> Error (Parse_error "Email/set method response not found") | e -> Error (Parse_error (Printexc.to_string e)) in Lwt.return result | Error e -> Lwt.return (Error e) (** Set a flag color for an email @param conn The JMAP connection @param account_id The account ID @param email_id The email ID @param color The flag color to set @return Success or error TODO:claude *) let set_flag_color conn ~account_id ~email_id ~color = (* Get the bit pattern for the color *) let (bit0, bit1, bit2) = Types.bits_of_flag_color color in (* Build the keywords update object *) let keywords = [ ("$flagged", `Bool true); ("$MailFlagBit0", `Bool bit0); ("$MailFlagBit1", `Bool bit1); ("$MailFlagBit2", `Bool bit2); ] in let request = { using = [ Jmap.Capability.to_string Jmap.Capability.Core; Capability.to_string Capability.Mail ]; method_calls = [ { name = "Email/set"; arguments = `O [ ("accountId", `String account_id); ("update", `O [ (email_id, `O [ ("keywords", `O keywords) ]) ]); ]; 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/set") response.method_responses in let args = method_response.arguments in match Ezjsonm.find_opt args ["updated"] with | Some (`A _ids) -> Ok () | _ -> match Ezjsonm.find_opt args ["notUpdated"] with | Some (`O _errors) -> Error (Parse_error ("Failed to update email: " ^ email_id)) | _ -> Error (Parse_error "Unexpected response format") with | Not_found -> Error (Parse_error "Email/set method response not found") | e -> Error (Parse_error (Printexc.to_string e)) in Lwt.return result | Error e -> Lwt.return (Error e) (** Convert an email's keywords to typed message_keyword list @param email The email to analyze @return List of message keywords TODO:claude *) let get_message_keywords (email:Types.email) = let open Types in List.filter_map (function | (Custom s, true) -> Some (message_keyword_of_string s) | _ -> None ) email.keywords (** Get emails with a specific message keyword @param conn The JMAP connection @param account_id The account ID @param keyword The message keyword to search for @param limit Optional limit on number of emails to return @return List of emails with the keyword if successful TODO:claude *) let get_emails_with_keyword conn ~account_id ~keyword ?limit () = let keyword_string = Types.string_of_message_keyword keyword in (* Query for emails with the specified keyword *) let query_request = { using = [ Jmap.Capability.to_string Jmap.Capability.Core; Capability.to_string Capability.Mail ]; method_calls = [ { name = "Email/query"; arguments = `O ([ ("accountId", `String account_id); ("filter", `O [("hasKeyword", `String keyword_string)]); ("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 = [ Jmap.Capability.to_string Jmap.Capability.Core; Capability.to_string 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 with the keyword *) 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) (** {1 Email Submission} *) (** Create a new email draft @param conn The JMAP connection @param account_id The account ID @param mailbox_id The mailbox ID to store the draft in (usually "drafts") @param from The sender's email address @param to_addresses List of recipient email addresses @param subject The email subject line @param text_body Plain text message body @param html_body Optional HTML message body @return The created email ID if successful TODO:claude *) let create_email_draft conn ~account_id ~mailbox_id ~from ~to_addresses ~subject ~text_body ?html_body () = (* Create email addresses *) let from_addr = { Types.name = None; email = from; parameters = []; } in let to_addrs = List.map (fun addr -> { Types.name = None; email = addr; parameters = []; }) to_addresses in (* Create text body part *) let text_part = { Types.part_id = Some "part1"; blob_id = None; size = None; headers = None; name = None; type_ = Some "text/plain"; charset = Some "utf-8"; disposition = None; cid = None; language = None; location = None; sub_parts = None; header_parameter_name = None; header_parameter_value = None; } in (* Create HTML body part if provided *) let html_part_opt = match html_body with | Some _html -> Some { Types.part_id = Some "part2"; blob_id = None; size = None; headers = None; name = None; type_ = Some "text/html"; charset = Some "utf-8"; disposition = None; cid = None; language = None; location = None; sub_parts = None; header_parameter_name = None; header_parameter_value = None; } | None -> None in (* Create body values *) let body_values = [ ("part1", text_body) ] @ (match html_body with | Some html -> [("part2", html)] | None -> [] ) in (* Create email *) let html_body_list = match html_part_opt with | Some part -> Some [part] | None -> None in let _email_creation = { Types.mailbox_ids = [(mailbox_id, true)]; keywords = Some [(Draft, true)]; received_at = None; (* Server will set this *) message_id = None; (* Server will generate this *) in_reply_to = None; references = None; sender = None; from = Some [from_addr]; to_ = Some to_addrs; cc = None; bcc = None; reply_to = None; subject = Some subject; body_values = Some body_values; text_body = Some [text_part]; html_body = html_body_list; attachments = None; headers = None; } in let request = { using = [ Jmap.Capability.to_string Jmap.Capability.Core; Capability.to_string Capability.Mail ]; method_calls = [ { name = "Email/set"; arguments = `O [ ("accountId", `String account_id); ("create", `O [ ("draft1", `O ( [ ("mailboxIds", `O [(mailbox_id, `Bool true)]); ("keywords", `O [("$draft", `Bool true)]); ("from", `A [`O [("name", `Null); ("email", `String from)]]); ("to", `A (List.map (fun addr -> `O [("name", `Null); ("email", `String addr)] ) to_addresses)); ("subject", `String subject); ("bodyStructure", `O [ ("type", `String "multipart/alternative"); ("subParts", `A [ `O [ ("partId", `String "part1"); ("type", `String "text/plain") ]; `O [ ("partId", `String "part2"); ("type", `String "text/html") ] ]) ]); ("bodyValues", `O ([ ("part1", `O [("value", `String text_body)]) ] @ (match html_body with | Some html -> [("part2", `O [("value", `String html)])] | None -> [("part2", `O [("value", `String ("" ^ text_body ^ ""))])] ))) ] )) ]) ]; 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/set") response.method_responses in let args = method_response.arguments in match Ezjsonm.find_opt args ["created"] with | Some (`O created) -> let draft_created = List.find_opt (fun (id, _) -> id = "draft1") created in (match draft_created with | Some (_, json) -> let id = Ezjsonm.get_string (Ezjsonm.find json ["id"]) in Ok id | None -> Error (Parse_error "Created email not found in response")) | _ -> match Ezjsonm.find_opt args ["notCreated"] with | Some (`O errors) -> let error_msg = match List.find_opt (fun (id, _) -> id = "draft1") errors with | Some (_, err) -> let type_ = Ezjsonm.get_string (Ezjsonm.find err ["type"]) in let description = match Ezjsonm.find_opt err ["description"] with | Some (`String desc) -> desc | _ -> "Unknown error" in "Error type: " ^ type_ ^ ", Description: " ^ description | None -> "Unknown error" in Error (Parse_error ("Failed to create email: " ^ error_msg)) | _ -> Error (Parse_error "Unexpected response format") with | Not_found -> Error (Parse_error "Email/set method response not found") | e -> Error (Parse_error (Printexc.to_string e)) in Lwt.return result | Error e -> Lwt.return (Error e) (** Get all identities for an account @param conn The JMAP connection @param account_id The account ID @return A list of identities if successful TODO:claude *) let get_identities conn ~account_id = let request = { using = [ Jmap.Capability.to_string Jmap.Capability.Core; Capability.to_string Capability.Submission ]; method_calls = [ { name = "Identity/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 = "Identity/get") response.method_responses in let args = method_response.arguments in match Ezjsonm.find_opt args ["list"] with | Some (`A identities) -> let parse_identity json = try let open Ezjsonm in let id = get_string (find json ["id"]) in let name = get_string (find json ["name"]) in let email = get_string (find json ["email"]) in let parse_email_addresses field = match find_opt json [field] with | Some (`A items) -> Some (List.map (fun addr_json -> let name = match find_opt addr_json ["name"] with | Some (`String s) -> Some s | Some (`Null) -> None | None -> None | _ -> None 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) -> match v with | `String s -> (k, s) | _ -> (k, "") ) items | _ -> [] in { Types.name; email; parameters } ) items) | _ -> None in let reply_to = parse_email_addresses "replyTo" in let bcc = parse_email_addresses "bcc" in let text_signature = match find_opt json ["textSignature"] with | Some (`String s) -> Some s | _ -> None in let html_signature = match find_opt json ["htmlSignature"] with | Some (`String s) -> Some s | _ -> None in let may_delete = match find_opt json ["mayDelete"] with | Some (`Bool b) -> b | _ -> false in (* Create our own identity record for simplicity *) let r : Types.identity = { id = id; name = name; email = email; reply_to = reply_to; bcc = bcc; text_signature = text_signature; html_signature = html_signature; may_delete = may_delete } in Ok r with | Not_found -> Error (Parse_error "Required field not found in identity object") | Invalid_argument msg -> Error (Parse_error msg) | e -> Error (Parse_error (Printexc.to_string e)) in let results = List.map parse_identity identities in let (successes, failures) = List.partition Result.is_ok results in if List.length failures > 0 then Error (Parse_error "Failed to parse some identity objects") else Ok (List.map Result.get_ok successes) | _ -> Error (Parse_error "Identity list not found in response") with | Not_found -> Error (Parse_error "Identity/get method response not found") | e -> Error (Parse_error (Printexc.to_string e)) in Lwt.return result | Error e -> Lwt.return (Error e) (** Find a suitable identity by email address @param conn The JMAP connection @param account_id The account ID @param email The email address to match @return The identity if found, otherwise Error TODO:claude *) let find_identity_by_email conn ~account_id ~email = let* identities_result = get_identities conn ~account_id in match identities_result with | Ok identities -> begin let matching_identity = List.find_opt (fun (identity:Types.identity) -> (* Exact match *) if String.lowercase_ascii identity.email = String.lowercase_ascii email then true else (* Wildcard match (e.g., *@example.com) *) let parts = String.split_on_char '@' identity.email in if List.length parts = 2 && List.hd parts = "*" then let domain = List.nth parts 1 in let email_parts = String.split_on_char '@' email in if List.length email_parts = 2 then List.nth email_parts 1 = domain else false else false ) identities in match matching_identity with | Some identity -> Lwt.return (Ok identity) | None -> Lwt.return (Error (Parse_error "No matching identity found")) end | Error e -> Lwt.return (Error e) (** Submit an email for delivery @param conn The JMAP connection @param account_id The account ID @param identity_id The identity ID to send from @param email_id The email ID to submit @param envelope Optional custom envelope @return The submission ID if successful TODO:claude *) let submit_email conn ~account_id ~identity_id ~email_id ?envelope () = let request = { using = [ Jmap.Capability.to_string Jmap.Capability.Core; Capability.to_string Capability.Mail; Capability.to_string Capability.Submission ]; method_calls = [ { name = "EmailSubmission/set"; arguments = `O [ ("accountId", `String account_id); ("create", `O [ ("submission1", `O ( [ ("emailId", `String email_id); ("identityId", `String identity_id); ] @ (match envelope with | Some env -> [ ("envelope", `O [ ("mailFrom", `O [ ("email", `String env.Types.mail_from.email); ("parameters", match env.Types.mail_from.parameters with | Some params -> `O (List.map (fun (k, v) -> (k, `String v)) params) | None -> `O [] ) ]); ("rcptTo", `A (List.map (fun (rcpt:Types.submission_address) -> `O [ ("email", `String rcpt.Types.email); ("parameters", match rcpt.Types.parameters with | Some params -> `O (List.map (fun (k, v) -> (k, `String v)) params) | None -> `O [] ) ] ) env.Types.rcpt_to)) ]) ] | None -> [] ) )) ]); ("onSuccessUpdateEmail", `O [ (email_id, `O [ ("keywords", `O [ ("$draft", `Bool false); ("$sent", `Bool true); ]) ]) ]); ]; 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 = "EmailSubmission/set") response.method_responses in let args = method_response.arguments in match Ezjsonm.find_opt args ["created"] with | Some (`O created) -> let submission_created = List.find_opt (fun (id, _) -> id = "submission1") created in (match submission_created with | Some (_, json) -> let id = Ezjsonm.get_string (Ezjsonm.find json ["id"]) in Ok id | None -> Error (Parse_error "Created submission not found in response")) | _ -> match Ezjsonm.find_opt args ["notCreated"] with | Some (`O errors) -> let error_msg = match List.find_opt (fun (id, _) -> id = "submission1") errors with | Some (_, err) -> let type_ = Ezjsonm.get_string (Ezjsonm.find err ["type"]) in let description = match Ezjsonm.find_opt err ["description"] with | Some (`String desc) -> desc | _ -> "Unknown error" in "Error type: " ^ type_ ^ ", Description: " ^ description | None -> "Unknown error" in Error (Parse_error ("Failed to submit email: " ^ error_msg)) | _ -> Error (Parse_error "Unexpected response format") with | Not_found -> Error (Parse_error "EmailSubmission/set method response not found") | e -> Error (Parse_error (Printexc.to_string e)) in Lwt.return result | Error e -> Lwt.return (Error e) (** Create and submit an email in one operation @param conn The JMAP connection @param account_id The account ID @param from The sender's email address @param to_addresses List of recipient email addresses @param subject The email subject line @param text_body Plain text message body @param html_body Optional HTML message body @return The submission ID if successful TODO:claude *) let create_and_submit_email conn ~account_id ~from ~to_addresses ~subject ~text_body ?html_body:_ () = (* First get accounts to find the draft mailbox and identity in a single request *) let* initial_result = let request = { using = [ Jmap.Capability.to_string Jmap.Capability.Core; Capability.to_string Capability.Mail; Capability.to_string Capability.Submission ]; method_calls = [ { name = "Mailbox/get"; arguments = `O [ ("accountId", `String account_id); ]; method_call_id = "m1"; }; { name = "Identity/get"; arguments = `O [ ("accountId", `String account_id) ]; method_call_id = "m2"; } ]; created_ids = None; } in make_request conn.config request in match initial_result with | Ok initial_response -> begin (* Find drafts mailbox ID *) let find_drafts_result = try let method_response = List.find (fun (inv : Ezjsonm.value Jmap.Types.invocation) -> inv.name = "Mailbox/get") initial_response.method_responses in let args = method_response.arguments in match Ezjsonm.find_opt args ["list"] with | Some (`A mailboxes) -> begin let draft_mailbox = List.find_opt (fun mailbox -> match Ezjsonm.find_opt mailbox ["role"] with | Some (`String role) -> role = "drafts" | _ -> false ) mailboxes in match draft_mailbox with | Some mb -> Ok (Ezjsonm.get_string (Ezjsonm.find mb ["id"])) | None -> Error (Parse_error "No drafts mailbox found") end | _ -> 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 (* Find matching identity for from address *) let find_identity_result = try let method_response = List.find (fun (inv : Ezjsonm.value Jmap.Types.invocation) -> inv.name = "Identity/get") initial_response.method_responses in let args = method_response.arguments in match Ezjsonm.find_opt args ["list"] with | Some (`A identities) -> begin let matching_identity = List.find_opt (fun identity -> match Ezjsonm.find_opt identity ["email"] with | Some (`String email) -> let email_lc = String.lowercase_ascii email in let from_lc = String.lowercase_ascii from in email_lc = from_lc || (* Exact match *) (* Wildcard domain match *) (let parts = String.split_on_char '@' email_lc in if List.length parts = 2 && List.hd parts = "*" then let domain = List.nth parts 1 in let from_parts = String.split_on_char '@' from_lc in if List.length from_parts = 2 then List.nth from_parts 1 = domain else false else false) | _ -> false ) identities in match matching_identity with | Some id -> let identity_id = Ezjsonm.get_string (Ezjsonm.find id ["id"]) in Ok identity_id | None -> Error (Parse_error ("No matching identity found for " ^ from)) end | _ -> Error (Parse_error "Identity list not found in response") with | Not_found -> Error (Parse_error "Identity/get method response not found") | e -> Error (Parse_error (Printexc.to_string e)) in (* If we have both required IDs, create and submit the email in one request *) match (find_drafts_result, find_identity_result) with | (Ok drafts_id, Ok identity_id) -> begin (* Now create and submit the email in a single request *) let request = { using = [ Jmap.Capability.to_string Jmap.Capability.Core; Capability.to_string Capability.Mail; Capability.to_string Capability.Submission ]; method_calls = [ { name = "Email/set"; arguments = `O [ ("accountId", `String account_id); ("create", `O [ ("draft", `O ( [ ("mailboxIds", `O [(drafts_id, `Bool true)]); ("keywords", `O [("$draft", `Bool true)]); ("from", `A [`O [("email", `String from)]]); ("to", `A (List.map (fun addr -> `O [("email", `String addr)] ) to_addresses)); ("subject", `String subject); ("textBody", `A [`O [ ("partId", `String "body"); ("type", `String "text/plain") ]]); ("bodyValues", `O [ ("body", `O [ ("charset", `String "utf-8"); ("value", `String text_body) ]) ]) ] )) ]); ]; method_call_id = "0"; }; { name = "EmailSubmission/set"; arguments = `O [ ("accountId", `String account_id); ("create", `O [ ("sendIt", `O [ ("emailId", `String "#draft"); ("identityId", `String identity_id) ]) ]) ]; method_call_id = "1"; } ]; created_ids = None; } in let* submit_result = make_request conn.config request in match submit_result with | Ok submit_response -> begin try let submission_method = List.find (fun (inv : Ezjsonm.value Jmap.Types.invocation) -> inv.name = "EmailSubmission/set") submit_response.method_responses in let args = submission_method.arguments in (* Check if email was created and submission was created *) match Ezjsonm.find_opt args ["created"] with | Some (`O created) -> begin (* Extract the submission ID *) let submission_created = List.find_opt (fun (id, _) -> id = "sendIt") created in match submission_created with | Some (_, json) -> let id = Ezjsonm.get_string (Ezjsonm.find json ["id"]) in Lwt.return (Ok id) | None -> begin (* Check if there was an error in creation *) match Ezjsonm.find_opt args ["notCreated"] with | Some (`O errors) -> let error_msg = match List.find_opt (fun (id, _) -> id = "sendIt") errors with | Some (_, err) -> let type_ = Ezjsonm.get_string (Ezjsonm.find err ["type"]) in let description = match Ezjsonm.find_opt err ["description"] with | Some (`String desc) -> desc | _ -> "Unknown error" in "Error type: " ^ type_ ^ ", Description: " ^ description | None -> "Unknown error" in Lwt.return (Error (Parse_error ("Failed to submit email: " ^ error_msg))) | Some _ -> Lwt.return (Error (Parse_error "Email submission not found in response")) | None -> Lwt.return (Error (Parse_error "Email submission not found in response")) end end | Some (`Null) -> Lwt.return (Error (Parse_error "No created submissions in response")) | Some _ -> Lwt.return (Error (Parse_error "Invalid response format for created submissions")) | None -> Lwt.return (Error (Parse_error "No created submissions in response")) with | Not_found -> Lwt.return (Error (Parse_error "EmailSubmission/set method response not found")) | e -> Lwt.return (Error (Parse_error (Printexc.to_string e))) end | Error e -> Lwt.return (Error e) end | (Error e, _) -> Lwt.return (Error e) | (_, Error e) -> Lwt.return (Error e) end | Error e -> Lwt.return (Error e) (** Get status of an email submission @param conn The JMAP connection @param account_id The account ID @param submission_id The email submission ID @return The submission status if successful TODO:claude *) let get_submission_status conn ~account_id ~submission_id = let request = { using = [ Jmap.Capability.to_string Jmap.Capability.Core; Capability.to_string Capability.Submission ]; method_calls = [ { name = "EmailSubmission/get"; arguments = `O [ ("accountId", `String account_id); ("ids", `A [`String submission_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 = "EmailSubmission/get") response.method_responses in let args = method_response.arguments in match Ezjsonm.find_opt args ["list"] with | Some (`A [submission]) -> let parse_submission json = try let open Ezjsonm in let id = get_string (find json ["id"]) in let identity_id = get_string (find json ["identityId"]) in let email_id = get_string (find json ["emailId"]) in let thread_id = get_string (find json ["threadId"]) in let envelope = match find_opt json ["envelope"] with | Some (`O env) -> begin let parse_address addr_json = let email = get_string (find addr_json ["email"]) in let parameters = match find_opt addr_json ["parameters"] with | Some (`O params) -> Some (List.map (fun (k, v) -> (k, get_string v)) params) | _ -> None in { Types.email; parameters } in let mail_from = parse_address (find (`O env) ["mailFrom"]) in let rcpt_to = match find (`O env) ["rcptTo"] with | `A rcpts -> List.map parse_address rcpts | _ -> [] in Some { Types.mail_from; rcpt_to } end | _ -> None in let send_at = match find_opt json ["sendAt"] with | Some (`String date) -> Some date | _ -> None in let undo_status = match find_opt json ["undoStatus"] with | Some (`String "pending") -> Some `pending | Some (`String "final") -> Some `final | Some (`String "canceled") -> Some `canceled | _ -> None in let parse_delivery_status deliveries = match deliveries with | `O statuses -> Some (List.map (fun (email, status_json) -> let smtp_reply = get_string (find status_json ["smtpReply"]) in let delivered = match find_opt status_json ["delivered"] with | Some (`String d) -> Some d | _ -> None in (email, { Types.smtp_reply; delivered }) ) statuses) | _ -> None in let delivery_status = match find_opt json ["deliveryStatus"] with | Some status -> parse_delivery_status status | _ -> None in let dsn_blob_ids = match find_opt json ["dsnBlobIds"] with | Some (`O ids) -> Some (List.map (fun (email, id) -> (email, get_string id)) ids) | _ -> None in let mdn_blob_ids = match find_opt json ["mdnBlobIds"] with | Some (`O ids) -> Some (List.map (fun (email, id) -> (email, get_string id)) ids) | _ -> None in Ok { Types.id; identity_id; email_id; thread_id; envelope; send_at; undo_status; delivery_status; dsn_blob_ids; mdn_blob_ids; } with | Not_found -> Error (Parse_error "Required field not found in submission object") | Invalid_argument msg -> Error (Parse_error msg) | e -> Error (Parse_error (Printexc.to_string e)) in parse_submission submission | Some (`A []) -> Error (Parse_error ("Submission not found: " ^ submission_id)) | _ -> Error (Parse_error "Expected single submission in response") with | Not_found -> Error (Parse_error "EmailSubmission/get method response not found") | e -> Error (Parse_error (Printexc.to_string e)) in Lwt.return result | Error e -> Lwt.return (Error e) (** {1 Email Address Utilities} *) (** Custom implementation of substring matching *) let contains_substring str sub = try let _ = Str.search_forward (Str.regexp_string sub) str 0 in true with Not_found -> false (** Checks if a pattern with wildcards matches a string @param pattern Pattern string with * and ? wildcards @param str String to match against Based on simple recursive wildcard matching algorithm *) let matches_wildcard pattern str = let pattern_len = String.length pattern in let str_len = String.length str in (* Convert both to lowercase for case-insensitive matching *) let pattern = String.lowercase_ascii pattern in let str = String.lowercase_ascii str in (* If there are no wildcards, do a simple substring check *) if not (String.contains pattern '*' || String.contains pattern '?') then contains_substring str pattern else (* Classic recursive matching algorithm *) let rec match_from p_pos s_pos = (* Pattern matched to the end *) if p_pos = pattern_len then s_pos = str_len (* Star matches zero or more chars *) else if pattern.[p_pos] = '*' then match_from (p_pos + 1) s_pos || (* Match empty string *) (s_pos < str_len && match_from p_pos (s_pos + 1)) (* Match one more char *) (* If both have more chars and they match or ? wildcard *) else if s_pos < str_len && (pattern.[p_pos] = '?' || pattern.[p_pos] = str.[s_pos]) then match_from (p_pos + 1) (s_pos + 1) else false in match_from 0 0 (** Check if an email address matches a filter string @param email The email address to check @param pattern The filter pattern to match against @return True if the email address matches the filter *) let email_address_matches email pattern = matches_wildcard pattern email (** Check if an email matches a sender filter @param email The email object to check @param pattern The sender filter pattern @return True if any sender address matches the filter *) let email_matches_sender (email : Types.email) pattern = (* Helper to extract emails from address list *) let addresses_match addrs = List.exists (fun (addr : Types.email_address) -> email_address_matches addr.email pattern ) addrs in (* Check From addresses first *) let from_match = match email.Types.from with | Some addrs -> addresses_match addrs | None -> false in (* If no match in From, check Sender field *) if from_match then true else match email.Types.sender with | Some addrs -> addresses_match addrs | None -> false