this repo has no description
at if-only 14 kB view raw
1(* 2 * jmap_email_composer.ml - Compose and send emails via JMAP 3 * 4 * This binary demonstrates JMAP's email creation and submission capabilities, 5 * including drafts, attachments, and sending. 6 * 7 * For step 2, we're only testing type checking. No implementations required. 8 *) 9 10open Cmdliner 11 12(** Email composition options **) 13type compose_options = { 14 to_recipients : string list; 15 cc_recipients : string list; 16 bcc_recipients : string list; 17 subject : string; 18 body_text : string option; 19 body_html : string option; 20 attachments : string list; 21 in_reply_to : string option; 22 draft : bool; 23 send : bool; 24} 25 26(** Command-line arguments **) 27 28let host_arg = 29 Arg.(required & opt (some string) None & info ["h"; "host"] 30 ~docv:"HOST" ~doc:"JMAP server hostname") 31 32let user_arg = 33 Arg.(required & opt (some string) None & info ["u"; "user"] 34 ~docv:"USERNAME" ~doc:"Username for authentication") 35 36let password_arg = 37 Arg.(required & opt (some string) None & info ["p"; "password"] 38 ~docv:"PASSWORD" ~doc:"Password for authentication") 39 40let to_arg = 41 Arg.(value & opt_all string [] & info ["t"; "to"] 42 ~docv:"EMAIL" ~doc:"Recipient email address (can be specified multiple times)") 43 44let cc_arg = 45 Arg.(value & opt_all string [] & info ["c"; "cc"] 46 ~docv:"EMAIL" ~doc:"CC recipient email address") 47 48let bcc_arg = 49 Arg.(value & opt_all string [] & info ["b"; "bcc"] 50 ~docv:"EMAIL" ~doc:"BCC recipient email address") 51 52let subject_arg = 53 Arg.(required & opt (some string) None & info ["s"; "subject"] 54 ~docv:"SUBJECT" ~doc:"Email subject line") 55 56let body_arg = 57 Arg.(value & opt (some string) None & info ["body"] 58 ~docv:"TEXT" ~doc:"Plain text body content") 59 60let body_file_arg = 61 Arg.(value & opt (some string) None & info ["body-file"] 62 ~docv:"FILE" ~doc:"Read body content from file") 63 64let html_arg = 65 Arg.(value & opt (some string) None & info ["html"] 66 ~docv:"HTML" ~doc:"HTML body content") 67 68let html_file_arg = 69 Arg.(value & opt (some string) None & info ["html-file"] 70 ~docv:"FILE" ~doc:"Read HTML body from file") 71 72let attach_arg = 73 Arg.(value & opt_all string [] & info ["a"; "attach"] 74 ~docv:"FILE" ~doc:"File to attach (can be specified multiple times)") 75 76let reply_to_arg = 77 Arg.(value & opt (some string) None & info ["r"; "reply-to"] 78 ~docv:"EMAIL_ID" ~doc:"Email ID to reply to") 79 80let draft_arg = 81 Arg.(value & flag & info ["d"; "draft"] 82 ~doc:"Save as draft instead of sending") 83 84let send_arg = 85 Arg.(value & flag & info ["send"] 86 ~doc:"Send the email immediately (default is to create draft)") 87 88(** Helper functions **) 89 90(* Read file contents *) 91let read_file filename = 92 let ic = open_in filename in 93 let len = in_channel_length ic in 94 let content = really_input_string ic len in 95 close_in ic; 96 content 97 98(* Get MIME type from filename *) 99let mime_type_from_filename filename = 100 match Filename.extension filename with 101 | ".pdf" -> "application/pdf" 102 | ".doc" | ".docx" -> "application/msword" 103 | ".xls" | ".xlsx" -> "application/vnd.ms-excel" 104 | ".jpg" | ".jpeg" -> "image/jpeg" 105 | ".png" -> "image/png" 106 | ".gif" -> "image/gif" 107 | ".txt" -> "text/plain" 108 | ".html" | ".htm" -> "text/html" 109 | ".zip" -> "application/zip" 110 | _ -> "application/octet-stream" 111 112(* Upload a file as a blob *) 113let upload_attachment ctx session account_id filepath = 114 Printf.printf "Uploading %s...\n" filepath; 115 116 let content = read_file filepath in 117 let filename = Filename.basename filepath in 118 let mime_type = mime_type_from_filename filename in 119 120 (* Upload blob using the JMAP upload endpoint *) 121 let upload_url = Jmap.Session.Session.upload_url session in 122 let upload_endpoint = Printf.sprintf "%s/%s" (Uri.to_string upload_url) account_id in 123 124 (* Simulate blob upload for type checking *) 125 Printf.printf " Would upload to: %s\n" upload_endpoint; 126 Printf.printf " Simulating upload of %s (%s, %d bytes)...\n" filename mime_type (String.length content); 127 128 (* Create simulated blob info *) 129 let blob_info = Jmap.Binary.Upload_response.v 130 ~account_id:"" 131 ~blob_id:("blob-" ^ filename ^ "-" ^ string_of_int (Random.int 99999)) 132 ~type_:mime_type 133 ~size:(String.length content) 134 () in 135 Printf.printf " Uploaded: %s (blob: %s, %d bytes)\n" 136 filename 137 (Jmap.Binary.Upload_response.blob_id blob_info) 138 (Jmap.Binary.Upload_response.size blob_info); 139 Ok blob_info 140 141(* Create email body parts *) 142let create_body_parts options attachment_blobs = 143 let parts = ref [] in 144 145 (* Add text body if provided *) 146 (match options.body_text with 147 | Some text -> 148 let text_part = Jmap_email.Types.Email_body_part.v 149 ~id:"text" 150 ~size:(String.length text) 151 ~headers:[] 152 ~mime_type:"text/plain" 153 ~charset:"utf-8" 154 () in 155 parts := text_part :: !parts 156 | None -> ()); 157 158 (* Add HTML body if provided *) 159 (match options.body_html with 160 | Some html -> 161 let html_part = Jmap_email.Types.Email_body_part.v 162 ~id:"html" 163 ~size:(String.length html) 164 ~headers:[] 165 ~mime_type:"text/html" 166 ~charset:"utf-8" 167 () in 168 parts := html_part :: !parts 169 | None -> ()); 170 171 (* Add attachments *) 172 List.iter2 (fun filepath blob_info -> 173 let filename = Filename.basename filepath in 174 let mime_type = mime_type_from_filename filename in 175 let attachment = Jmap_email.Types.Email_body_part.v 176 ~blob_id:(Jmap.Binary.Upload_response.blob_id blob_info) 177 ~size:(Jmap.Binary.Upload_response.size blob_info) 178 ~headers:[] 179 ~name:filename 180 ~mime_type 181 ~disposition:"attachment" 182 () in 183 parts := attachment :: !parts 184 ) options.attachments attachment_blobs; 185 186 List.rev !parts 187 188(* Main compose and send function *) 189let compose_and_send ctx session account_id options = 190 (* 1. Upload attachments first *) 191 let attachment_results = List.map (fun filepath -> 192 upload_attachment ctx session account_id filepath 193 ) options.attachments in 194 195 let attachment_blobs = List.filter_map (function 196 | Ok blob -> Some blob 197 | Error () -> None 198 ) attachment_results in 199 200 if List.length attachment_blobs < List.length options.attachments then ( 201 Printf.eprintf "Warning: Some attachments failed to upload\n" 202 ); 203 204 (* 2. Create the email addresses *) 205 let to_addresses = List.map (fun email -> 206 Jmap_email.Types.Email_address.v ~email () 207 ) options.to_recipients in 208 209 let cc_addresses = List.map (fun email -> 210 Jmap_email.Types.Email_address.v ~email () 211 ) options.cc_recipients in 212 213 let bcc_addresses = List.map (fun email -> 214 Jmap_email.Types.Email_address.v ~email () 215 ) options.bcc_recipients in 216 217 (* 3. Get sender identity *) 218 let identity_args = Jmap.Methods.Get_args.v 219 ~account_id 220 ~properties:["id"; "email"; "name"] 221 () in 222 223 let identity_invocation = Jmap.Wire.Invocation.v 224 ~method_name:"Identity/get" 225 ~arguments:(`Assoc []) (* Would serialize identity_args *) 226 ~method_call_id:"id1" 227 () in 228 229 let request = Jmap.Wire.Request.v 230 ~using:[Jmap.capability_core; Jmap_email.capability_mail] 231 ~method_calls:[identity_invocation] 232 () in 233 234 let default_identity = match Jmap_unix.request ctx request with 235 | Ok _ -> 236 (* Would extract from response *) 237 Jmap_email.Identity.v 238 ~id:"identity1" 239 ~email:account_id 240 ~name:"User Name" 241 ~may_delete:true 242 () 243 | Error _ -> 244 (* Fallback identity *) 245 Jmap_email.Identity.v 246 ~id:"identity1" 247 ~email:account_id 248 ~may_delete:true 249 () 250 in 251 252 (* 4. Create the draft email *) 253 let body_parts = create_body_parts options attachment_blobs in 254 255 let draft_email = Jmap_email.Types.Email.create 256 ~subject:options.subject 257 ~from:[Jmap_email.Types.Email_address.v 258 ~email:(Jmap_email.Identity.email default_identity) 259 ~name:(Jmap_email.Identity.name default_identity) 260 ()] 261 ~to_:to_addresses 262 ~cc:cc_addresses 263 ~keywords:(Jmap_email.Types.Keywords.of_list [Jmap_email.Types.Keywords.Draft]) 264 ~text_body:body_parts 265 () in 266 267 (* 5. Create the email using Email/set *) 268 let create_map = Hashtbl.create 1 in 269 Hashtbl.add create_map "draft1" draft_email; 270 271 let create_args = Jmap.Methods.Set_args.v 272 ~account_id 273 ~create:create_map 274 () in 275 276 let create_invocation = Jmap.Wire.Invocation.v 277 ~method_name:"Email/set" 278 ~arguments:(`Assoc []) (* Would serialize create_args *) 279 ~method_call_id:"create1" 280 () in 281 282 (* 6. If sending, also create EmailSubmission *) 283 let method_calls = if options.send && not options.draft then 284 let submission = { 285 Jmap_email.Submission.email_sub_create_identity_id = Jmap_email.Identity.id default_identity; 286 email_sub_create_email_id = "#draft1"; (* Back-reference to created email *) 287 email_sub_create_envelope = None; 288 } in 289 290 let submit_map = Hashtbl.create 1 in 291 Hashtbl.add submit_map "submission1" submission; 292 293 let submit_args = Jmap.Methods.Set_args.v 294 ~account_id 295 ~create:submit_map 296 () in 297 298 let submit_invocation = Jmap.Wire.Invocation.v 299 ~method_name:"EmailSubmission/set" 300 ~arguments:(`Assoc []) (* Would serialize submit_args *) 301 ~method_call_id:"submit1" 302 () in 303 304 [create_invocation; submit_invocation] 305 else 306 [create_invocation] 307 in 308 309 (* 7. Send the request *) 310 let request = Jmap.Wire.Request.v 311 ~using:[Jmap.capability_core; Jmap_email.capability_mail; Jmap_email.capability_submission] 312 ~method_calls 313 () in 314 315 match Jmap_unix.request ctx request with 316 | Ok response -> 317 if options.send && not options.draft then 318 Printf.printf "\nEmail sent successfully!\n" 319 else 320 Printf.printf "\nDraft saved successfully!\n"; 321 0 322 | Error e -> 323 Printf.eprintf "\nFailed to create email: %s\n" (Jmap.Error.error_to_string e); 324 1 325 326(* Command implementation *) 327let compose_command host user password to_list cc_list bcc_list subject 328 body body_file html html_file attachments reply_to 329 draft send : int = 330 Printf.printf "JMAP Email Composer\n"; 331 Printf.printf "Server: %s\n" host; 332 Printf.printf "User: %s\n\n" user; 333 334 (* Validate arguments *) 335 if to_list = [] && cc_list = [] && bcc_list = [] then ( 336 Printf.eprintf "Error: Must specify at least one recipient\n"; 337 exit 1 338 ); 339 340 (* Read body content *) 341 let body_text = match body, body_file with 342 | Some text, _ -> Some text 343 | None, Some file -> Some (read_file file) 344 | None, None -> None 345 in 346 347 let body_html = match html, html_file with 348 | Some text, _ -> Some text 349 | None, Some file -> Some (read_file file) 350 | None, None -> None 351 in 352 353 if body_text = None && body_html = None then ( 354 Printf.eprintf "Error: Must provide email body (--body, --body-file, --html, or --html-file)\n"; 355 exit 1 356 ); 357 358 (* Create options record *) 359 let options = { 360 to_recipients = to_list; 361 cc_recipients = cc_list; 362 bcc_recipients = bcc_list; 363 subject; 364 body_text; 365 body_html; 366 attachments; 367 in_reply_to = reply_to; 368 draft; 369 send = send || not draft; (* Send by default unless draft flag is set *) 370 } in 371 372 (* Connect to server *) 373 let ctx = Jmap_unix.create_client () in 374 let result = Jmap_unix.quick_connect ~host ~username:user ~password in 375 376 let (ctx, session) = match result with 377 | Ok (ctx, session) -> (ctx, session) 378 | Error e -> 379 Printf.eprintf "Connection failed: %s\n" (Jmap.Error.error_to_string e); 380 exit 1 381 in 382 383 (* Get the primary account ID *) 384 let account_id = match Jmap.get_primary_account session Jmap_email.capability_mail with 385 | Ok id -> id 386 | Error e -> 387 Printf.eprintf "No mail account found: %s\n" (Jmap.Error.error_to_string e); 388 exit 1 389 in 390 391 (* Compose and send/save the email *) 392 compose_and_send ctx session account_id options 393 394(* Command definition *) 395let compose_cmd = 396 let doc = "compose and send emails via JMAP" in 397 let man = [ 398 `S Manpage.s_description; 399 `P "Compose and send emails using the JMAP protocol."; 400 `P "Supports plain text and HTML bodies, attachments, and drafts."; 401 `S Manpage.s_examples; 402 `P "Send a simple email:"; 403 `P " $(mname) -h jmap.example.com -u user@example.com -p secret123 \\"; 404 `P " -t recipient@example.com -s \"Meeting reminder\" \\"; 405 `P " --body \"Don't forget our meeting at 3pm!\""; 406 `P ""; 407 `P "Send email with attachment:"; 408 `P " $(mname) -h jmap.example.com -u user@example.com -p secret123 \\"; 409 `P " -t recipient@example.com -s \"Report attached\" \\"; 410 `P " --body-file message.txt -a report.pdf"; 411 `P ""; 412 `P "Save as draft:"; 413 `P " $(mname) -h jmap.example.com -u user@example.com -p secret123 \\"; 414 `P " -t recipient@example.com -s \"Work in progress\" \\"; 415 `P " --body \"Still working on this...\" --draft"; 416 ] in 417 418 let cmd = 419 Cmd.v 420 (Cmd.info "jmap-email-composer" ~version:"1.0" ~doc ~man) 421 Term.(const compose_command $ host_arg $ user_arg $ password_arg $ 422 to_arg $ cc_arg $ bcc_arg $ subject_arg $ body_arg $ body_file_arg $ 423 html_arg $ html_file_arg $ attach_arg $ reply_to_arg $ 424 draft_arg $ send_arg) 425 in 426 cmd 427 428(* Main entry point *) 429let () = exit (Cmd.eval' compose_cmd)