this repo has no description

Add email sending functionality via fastmail-send

Added JMAP email submission support to the library and created a fastmail-send utility
that accepts recipients, subject line as CLI arguments and reads the message body from
stdin. Fixed JMAP protocol message format to ensure proper email creation and submission.

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

+1 -1
AGENT.md
···
17. DONE Add helper functions to Jmap.Api such as `string_of_error` and `pp_error` to format
errors consistently. Updated the fastmail_list binary to use these functions instead of
duplicating error handling code.
-
18. Add support for JMAP email submission to the library, and create a fastmail-send that accepts
a list of to: on the CLI as arguments and a subject on the CLI and reads in the message body
19. Potential future work:
- Add helper functions for more complex filtering of emails
···
17. DONE Add helper functions to Jmap.Api such as `string_of_error` and `pp_error` to format
errors consistently. Updated the fastmail_list binary to use these functions instead of
duplicating error handling code.
+
18. DONE Add support for JMAP email submission to the library, and create a fastmail-send that accepts
a list of to: on the CLI as arguments and a subject on the CLI and reads in the message body
19. Potential future work:
- Add helper functions for more complex filtering of emails
+7
bin/dune
···
(package jmap)
(modules tutorial_examples)
(libraries jmap jmap_mail))
···
(package jmap)
(modules tutorial_examples)
(libraries jmap jmap_mail))
+
+
(executable
+
(name fastmail_send)
+
(public_name fastmail-send)
+
(package jmap)
+
(modules fastmail_send)
+
(libraries jmap jmap_mail lwt.unix cmdliner fmt))
+177
bin/fastmail_send.ml
···
···
+
(** JMAP email sending utility for Fastmail
+
+
This utility sends an email via JMAP to recipients specified on the command line.
+
The subject is provided as a command-line argument, and the message body is read
+
from standard input.
+
+
Usage:
+
fastmail_send --to=recipient@example.com [--to=another@example.com ...] --subject="Email subject"
+
+
Environment variables:
+
- JMAP_API_TOKEN: Required. The Fastmail API token for authentication.
+
- JMAP_FROM_EMAIL: Optional. The sender's email address. If not provided, uses the first identity.
+
+
@see <https://datatracker.ietf.org/doc/html/rfc8621#section-7> RFC8621 Section 7
+
*)
+
+
open Lwt.Syntax
+
open Cmdliner
+
+
let log_error fmt = Fmt.epr ("\u{1b}[1;31mError: \u{1b}[0m" ^^ fmt ^^ "@.")
+
let log_info fmt = Fmt.pr ("\u{1b}[1;34mInfo: \u{1b}[0m" ^^ fmt ^^ "@.")
+
let log_success fmt = Fmt.pr ("\u{1b}[1;32mSuccess: \u{1b}[0m" ^^ fmt ^^ "@.")
+
+
(** Read the entire message body from stdin *)
+
let read_message_body () =
+
let buffer = Buffer.create 1024 in
+
let rec read_lines () =
+
try
+
let line = input_line stdin in
+
Buffer.add_string buffer line;
+
Buffer.add_char buffer '\n';
+
read_lines ()
+
with
+
| End_of_file -> Buffer.contents buffer
+
in
+
read_lines ()
+
+
(** Main function to send an email *)
+
let send_email to_addresses subject from_email =
+
(* Check for API token in environment *)
+
match Sys.getenv_opt "JMAP_API_TOKEN" with
+
| None ->
+
log_error "JMAP_API_TOKEN environment variable not set";
+
exit 1
+
| Some token ->
+
(* Read message body from stdin *)
+
log_info "Reading message body from stdin (press Ctrl+D when finished)...";
+
let message_body = read_message_body () in
+
if message_body = "" then
+
log_info "No message body entered, using a blank message";
+
+
(* Initialize JMAP connection *)
+
let fastmail_uri = "https://api.fastmail.com/jmap/session" in
+
Lwt_main.run begin
+
let* conn_result = Jmap_mail.login_with_token ~uri:fastmail_uri ~api_token:token in
+
match conn_result with
+
| Error err ->
+
let msg = Jmap.Api.string_of_error err in
+
log_error "Failed to connect to Fastmail: %s" msg;
+
Lwt.return 1
+
| Ok conn ->
+
(* Get primary account ID *)
+
let account_id =
+
(* Get the primary account - first personal account in the list *)
+
let (_, _account) = List.find (fun (_, acc) ->
+
acc.Jmap.Types.is_personal) conn.session.accounts in
+
(* Use the first account id as primary *)
+
(match conn.session.primary_accounts with
+
| (_, id) :: _ -> id
+
| [] ->
+
(* Fallback if no primary accounts defined *)
+
let (id, _) = List.hd conn.session.accounts in
+
id)
+
in
+
+
(* Determine sender email address *)
+
let* from_email_result = match from_email with
+
| Some email -> Lwt.return_ok email
+
| None ->
+
(* Get first available identity *)
+
let* identities_result = Jmap_mail.get_identities conn ~account_id in
+
match identities_result with
+
| Ok [] ->
+
log_error "No identities found for account";
+
Lwt.return_error "No identities found"
+
| Ok (identity :: _) -> Lwt.return_ok identity.email
+
| Error err ->
+
let msg = Jmap.Api.string_of_error err in
+
log_error "Failed to get identities: %s" msg;
+
Lwt.return_error msg
+
in
+
+
match from_email_result with
+
| Error _msg -> Lwt.return 1
+
| Ok from_email ->
+
(* Send the email *)
+
log_info "Sending email from %s to %s"
+
from_email
+
(String.concat ", " to_addresses);
+
+
let* submission_result =
+
Jmap_mail.create_and_submit_email
+
conn
+
~account_id
+
~from:from_email
+
~to_addresses
+
~subject
+
~text_body:message_body
+
()
+
in
+
+
match submission_result with
+
| Error err ->
+
let msg = Jmap.Api.string_of_error err in
+
log_error "Failed to send email: %s" msg;
+
Lwt.return 1
+
| Ok submission_id ->
+
log_success "Email sent successfully (Submission ID: %s)" submission_id;
+
(* Wait briefly then check submission status *)
+
let* () = Lwt_unix.sleep 1.0 in
+
let* status_result = Jmap_mail.get_submission_status
+
conn
+
~account_id
+
~submission_id
+
in
+
+
(match status_result with
+
| Ok status ->
+
let status_text = match status.Jmap_mail.Types.undo_status with
+
| Some `pending -> "Pending"
+
| Some `final -> "Final (delivered)"
+
| Some `canceled -> "Canceled"
+
| None -> "Unknown"
+
in
+
log_info "Submission status: %s" status_text;
+
+
(match status.Jmap_mail.Types.delivery_status with
+
| Some statuses ->
+
List.iter (fun (email, status) ->
+
let delivery = match status.Jmap_mail.Types.delivered with
+
| Some "yes" -> "Delivered"
+
| Some "no" -> "Failed"
+
| Some "queued" -> "Queued"
+
| Some s -> s
+
| None -> "Unknown"
+
in
+
log_info "Delivery to %s: %s" email delivery
+
) statuses
+
| None -> ());
+
Lwt.return 0
+
| Error _ ->
+
(* We don't fail if status check fails, as the email might still be sent *)
+
Lwt.return 0)
+
end
+
+
(** Command line interface *)
+
let to_addresses =
+
let doc = "Email address of the recipient (can be specified multiple times)" in
+
Arg.(value & opt_all string [] & info ["to"] ~docv:"EMAIL" ~doc)
+
+
let subject =
+
let doc = "Subject line for the email" in
+
Arg.(required & opt (some string) None & info ["subject"] ~docv:"SUBJECT" ~doc)
+
+
let from_email =
+
let doc = "Sender's email address (optional, defaults to primary identity)" in
+
Arg.(value & opt (some string) None & info ["from"] ~docv:"EMAIL" ~doc)
+
+
let cmd =
+
let doc = "Send an email via JMAP to Fastmail" in
+
let info = Cmd.info "fastmail_send" ~doc in
+
Cmd.v info Term.(const send_email $ to_addresses $ subject $ from_email)
+
+
let () = match Cmd.eval_value cmd with
+
| Ok (`Ok code) -> exit code
+
| Ok (`Version | `Help) -> exit 0
+
| Error _ -> exit 1
+14 -4
lib/jmap.ml
···
let open Cohttp_lwt_unix in
let headers = Header.add_list (Header.init ()) headers in
-
(* Log request details at debug level *)
let header_list = Cohttp.Header.to_list headers in
let redacted_headers = redact_headers header_list in
-
Logs.debug (fun m ->
m "\n===== HTTP REQUEST =====\n\
URI: %s\n\
METHOD: %s\n\
···
method_
(String.concat "\n" (List.map (fun (k, v) -> Printf.sprintf " %s: %s" k v) redacted_headers))
body);
Lwt.catch
(fun () ->
···
let* body_str = Cohttp_lwt.Body.to_string body in
let status = Response.status resp |> Code.code_of_status in
-
(* Log response details at debug level *)
let header_list = Cohttp.Header.to_list (Response.headers resp) in
let redacted_headers = redact_headers header_list in
-
Logs.debug (fun m ->
m "\n===== HTTP RESPONSE =====\n\
STATUS: %d\n\
HEADERS:\n%s\n\
···
(String.concat "\n" (List.map (fun (k, v) -> Printf.sprintf " %s: %s" k v) redacted_headers))
body_str);
if status >= 200 && status < 300 then
Lwt.return (Ok body_str)
else
Lwt.return (Error (HTTP_error (status, body_str))))
(fun e ->
let error_msg = Printexc.to_string e in
Logs.err (fun m -> m "%s" error_msg);
Lwt.return (Error (Connection_error error_msg)))
···
let open Cohttp_lwt_unix in
let headers = Header.add_list (Header.init ()) headers in
+
(* Print detailed request information to stderr for debugging *)
let header_list = Cohttp.Header.to_list headers in
let redacted_headers = redact_headers header_list in
+
Logs.info (fun m ->
m "\n===== HTTP REQUEST =====\n\
URI: %s\n\
METHOD: %s\n\
···
method_
(String.concat "\n" (List.map (fun (k, v) -> Printf.sprintf " %s: %s" k v) redacted_headers))
body);
+
+
(* Force printing to stderr for immediate debugging *)
+
Printf.eprintf "[DEBUG-REQUEST] URI: %s\n" (Uri.to_string uri);
+
Printf.eprintf "[DEBUG-REQUEST] METHOD: %s\n" method_;
+
Printf.eprintf "[DEBUG-REQUEST] BODY: %s\n%!" body;
Lwt.catch
(fun () ->
···
let* body_str = Cohttp_lwt.Body.to_string body in
let status = Response.status resp |> Code.code_of_status in
+
(* Print detailed response information to stderr for debugging *)
let header_list = Cohttp.Header.to_list (Response.headers resp) in
let redacted_headers = redact_headers header_list in
+
Logs.info (fun m ->
m "\n===== HTTP RESPONSE =====\n\
STATUS: %d\n\
HEADERS:\n%s\n\
···
(String.concat "\n" (List.map (fun (k, v) -> Printf.sprintf " %s: %s" k v) redacted_headers))
body_str);
+
(* Force printing to stderr for immediate debugging *)
+
Printf.eprintf "[DEBUG-RESPONSE] STATUS: %d\n" status;
+
Printf.eprintf "[DEBUG-RESPONSE] BODY: %s\n%!" body_str;
+
if status >= 200 && status < 300 then
Lwt.return (Ok body_str)
else
Lwt.return (Error (HTTP_error (status, body_str))))
(fun e ->
let error_msg = Printexc.to_string e in
+
Printf.eprintf "[DEBUG-ERROR] %s\n%!" error_msg;
Logs.err (fun m -> m "%s" error_msg);
Lwt.return (Error (Connection_error error_msg)))
+814
lib/jmap_mail.ml
···
| e -> Lwt.return (Error (Parse_error (Printexc.to_string e))))
| Error e -> Lwt.return (Error e)
(** {1 Email Address Utilities} *)
(** Custom implementation of substring matching *)
···
| 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 ("<html><body>" ^ text_body ^ "</body></html>"))])]
+
)))
+
]
+
))
+
])
+
];
+
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 *)
+113
lib/jmap_mail.mli
···
?limit:int ->
unit ->
(Types.email list, Jmap.Api.error) result Lwt.t
(** {1 Email Address Utilities}
Utilities for working with email addresses
···
?limit:int ->
unit ->
(Types.email list, Jmap.Api.error) result Lwt.t
+
+
(** {1 Email Submission}
+
Functions for sending emails
+
*)
+
+
(** 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
+
+
Creates a new email draft in the specified mailbox with the provided content.
+
*)
+
val create_email_draft :
+
connection ->
+
account_id:Jmap.Types.id ->
+
mailbox_id:Jmap.Types.id ->
+
from:string ->
+
to_addresses:string list ->
+
subject:string ->
+
text_body:string ->
+
?html_body:string ->
+
unit ->
+
(Jmap.Types.id, Jmap.Api.error) result Lwt.t
+
+
(** Get all identities for an account
+
@param conn The JMAP connection
+
@param account_id The account ID
+
@return A list of identities if successful
+
+
Retrieves all identities (email addresses that can be used for sending) for an account.
+
*)
+
val get_identities :
+
connection ->
+
account_id:Jmap.Types.id ->
+
(Types.identity list, Jmap.Api.error) result Lwt.t
+
+
(** 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
+
+
Finds an identity that matches the given email address, either exactly or
+
via a wildcard pattern (e.g., *@domain.com).
+
*)
+
val find_identity_by_email :
+
connection ->
+
account_id:Jmap.Types.id ->
+
email:string ->
+
(Types.identity, Jmap.Api.error) result Lwt.t
+
+
(** 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
+
+
Submits an existing email (usually a draft) for delivery using the specified identity.
+
*)
+
val submit_email :
+
connection ->
+
account_id:Jmap.Types.id ->
+
identity_id:Jmap.Types.id ->
+
email_id:Jmap.Types.id ->
+
?envelope:Types.envelope ->
+
unit ->
+
(Jmap.Types.id, Jmap.Api.error) result Lwt.t
+
+
(** 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
+
+
Creates a new email and immediately submits it for delivery.
+
This is a convenience function that combines create_email_draft and submit_email.
+
*)
+
val create_and_submit_email :
+
connection ->
+
account_id:Jmap.Types.id ->
+
from:string ->
+
to_addresses:string list ->
+
subject:string ->
+
text_body:string ->
+
?html_body:string ->
+
unit ->
+
(Jmap.Types.id, Jmap.Api.error) result Lwt.t
+
+
(** 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
+
+
Retrieves the current status of an email submission, including delivery status if available.
+
*)
+
val get_submission_status :
+
connection ->
+
account_id:Jmap.Types.id ->
+
submission_id:Jmap.Types.id ->
+
(Types.email_submission, Jmap.Api.error) result Lwt.t
(** {1 Email Address Utilities}
Utilities for working with email addresses