diff --git a/CHANGELOG.md b/CHANGELOG.md index f29598b..09f16ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +## [Unreleased] + +### Added + +- **HTML email body**: `--html-body` (inline) and `--html-file` (from file) flags on send, reply, and forward. JMAP assembles multipart/alternative automatically when both text and HTML are provided. +- **File attachments**: `--attachment` / `-a` flag (repeatable) on send, reply, and forward. Files are uploaded as blobs and attached via JMAP's `bodyStructure` with proper multipart/mixed MIME tree. +- **`upload_blob` JMAP method**: POST raw bytes to Fastmail's upload endpoint, returns a `blobId` for use in email composition. +- **GraphQL `html_body` parameter**: `sendEmail`, `replyToEmail`, and `forwardEmail` mutations accept optional HTML body content. Previews indicate when HTML is included. + +### Changed + +- **Refactored body construction**: `create_and_submit_email` now handles plain text, text+HTML (multipart/alternative), and attachments (multipart/mixed with nested alternative) in a single code path. Eliminated duplicated body/cc/bcc logic across compose methods. +- `bodyValues` keys changed from `"body"` to `"textBody"` / `"htmlBody"` for clarity. +- `mime_from_filename` in util.rs is now public (used by `load_attachment`). + ## [2.0.1] - 2026-03-26 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index 5708417..e5edac7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -175,6 +175,16 @@ version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "71938f30533e4d95a6d17aa530939da3842c2ab6f4f84b9dae68447e4129f74a" +[[package]] +name = "assert-json-diff" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e4f2b81832e72834d7518d8487a0396a28cc408186a2e8854c0f98011faf12" +dependencies = [ + "serde", + "serde_json", +] + [[package]] name = "astral-tl" version = "0.7.11" @@ -970,6 +980,24 @@ dependencies = [ "time", ] +[[package]] +name = "deadpool" +version = "0.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0be2b1d1d6ec8d846f05e137292d0b89133caf95ef33695424c09568bdd39b1b" +dependencies = [ + "deadpool-runtime", + "lazy_static", + "num_cpus", + "tokio", +] + +[[package]] +name = "deadpool-runtime" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "092966b41edc516079bdf31ec78a2e0588d1d0c08f78b91d8307215928642b2b" + [[package]] name = "debug_unsafe" version = "0.1.4" @@ -1178,11 +1206,13 @@ dependencies = [ "schemars 0.8.22", "serde", "serde_json", + "tempfile", "thiserror 2.0.18", "tokio", "toml 0.8.23", "tracing", "tracing-subscriber", + "wiremock", ] [[package]] @@ -1456,6 +1486,25 @@ dependencies = [ "weezl", ] +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "half" version = "2.7.1" @@ -1618,6 +1667,12 @@ version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + [[package]] name = "hyper" version = "1.8.1" @@ -1628,9 +1683,11 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", + "httpdate", "itoa", "pin-project-lite", "pin-utils", @@ -4918,6 +4975,29 @@ dependencies = [ "memchr", ] +[[package]] +name = "wiremock" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08db1edfb05d9b3c1542e521aea074442088292f00b5f28e435c714a98f85031" +dependencies = [ + "assert-json-diff", + "base64", + "deadpool", + "futures", + "http", + "http-body-util", + "hyper", + "hyper-util", + "log", + "once_cell", + "regex", + "serde", + "serde_json", + "tokio", + "url", +] + [[package]] name = "wit-bindgen" version = "0.51.0" diff --git a/Cargo.toml b/Cargo.toml index c405ce6..916935a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,10 @@ tracing = "0.1.44" tracing-subscriber = { version = "0.3.22", features = ["env-filter"] } roxmltree = "0.21.1" +[dev-dependencies] +tempfile = "3" +wiremock = "0.6" + [profile.release] strip = true lto = true diff --git a/README.md b/README.md index 3036f83..40740ec 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ CLI for Fastmail's JMAP API. Read, search, send, and manage emails from your ter | Feature | Description | | --------------------- | ---------------------------------------------------------------------- | -| **Email** | List, search, read, send, reply, forward, threads, identity selection | +| **Email** | List, search, read, send, reply, forward, threads, identity selection, HTML bodies, file attachments | | **Mailboxes** | List folders, move emails, mark spam/read | | **Contacts** | Search contacts via CardDAV | | **Attachments** | Download files, extract text, resize images | @@ -158,6 +158,26 @@ fastmail-cli send \ --from "alias@yourdomain.com" \ --subject "Hello" \ --body "Message" + +# HTML email body (inline or from file) +fastmail-cli send \ + --to "alice@example.com" \ + --subject "Newsletter" \ + --body "Plain text fallback" \ + --html-body "

Hello

Rich content here

" + +fastmail-cli send \ + --to "alice@example.com" \ + --subject "Report" \ + --body "See attached" \ + --html-file ./email.html + +# File attachments (repeatable) +fastmail-cli send \ + --to "alice@example.com" \ + --subject "Documents" \ + --body "Please review" \ + -a report.pdf -a data.xlsx ``` ### Move Email diff --git a/src/jmap/mod.rs b/src/jmap/mod.rs index 8a6da04..5178666 100644 --- a/src/jmap/mod.rs +++ b/src/jmap/mod.rs @@ -35,12 +35,21 @@ pub async fn authenticated_client() -> crate::error::Result { Ok(client) } +/// File attachment data ready for upload +pub struct AttachmentData { + pub filename: String, + pub content_type: String, + pub data: Vec, +} + /// Common parameters for compose operations (send, reply, forward) pub struct ComposeParams<'a> { pub cc: Vec, pub bcc: Vec, pub from: Option<&'a str>, pub draft: bool, + pub html_body: Option, + pub attachments: Vec, } /// Threading headers for reply/forward @@ -56,9 +65,82 @@ struct EmailDraft<'a> { bcc: &'a [EmailAddress], subject: &'a str, body: &'a str, + html_body: Option<&'a str>, + attachments: Vec, threading: Option, } +/// An attachment after blob upload — holds the server-assigned blobId. +#[derive(Debug)] +struct UploadedAttachment { + blob_id: String, + filename: String, + content_type: String, +} + +/// Build bodyValues and body structure fields on `email_create`. +/// +/// Handles three JMAP body modes: +/// - Plain text only → `textBody` array +/// - Text + HTML (no attachments) → `textBody` + `htmlBody` arrays +/// - With attachments → explicit `bodyStructure` MIME tree +fn apply_body_structure( + email_create: &mut HashMap, + text_body: &str, + html_body: Option<&str>, + attachments: &[UploadedAttachment], +) { + let mut body_values = json!({ + "textBody": { "value": text_body, "charset": "utf-8" } + }); + if let Some(html) = html_body { + body_values["htmlBody"] = json!({ "value": html, "charset": "utf-8" }); + } + email_create.insert("bodyValues".into(), body_values); + + let has_html = html_body.is_some(); + let has_attachments = !attachments.is_empty(); + + if has_attachments { + let text_part = json!({ "partId": "textBody", "type": "text/plain" }); + let content_part = if has_html { + let html_part = json!({ "partId": "htmlBody", "type": "text/html" }); + json!({ "type": "multipart/alternative", "subParts": [text_part, html_part] }) + } else { + text_part + }; + + let mut sub_parts = vec![content_part]; + for att in attachments { + sub_parts.push(json!({ + "blobId": att.blob_id, + "name": att.filename, + "type": att.content_type, + "disposition": "attachment" + })); + } + + email_create.insert( + "bodyStructure".into(), + json!({ "type": "multipart/mixed", "subParts": sub_parts }), + ); + } else if has_html { + email_create.insert( + "textBody".into(), + json!([{ "partId": "textBody", "type": "text/plain" }]), + ); + email_create.insert( + "htmlBody".into(), + json!([{ "partId": "htmlBody", "type": "text/html" }]), + ); + } else { + email_create.insert( + "textBody".into(), + json!([{ "partId": "textBody", "type": "text/plain" }]), + ); + } +} + /// Resolved context for a compose operation struct ComposeContext { account_id: String, @@ -724,6 +806,7 @@ impl JmapClient { } /// Shared helper: build email_create map with common fields and submit it. + /// Handles plain text, HTML, and attachment body structures. async fn create_and_submit_email( &self, ctx: &ComposeContext, @@ -748,14 +831,25 @@ impl JmapClient { email_create.insert("bcc".into(), addrs_json(draft.bcc)); } email_create.insert("subject".into(), json!(draft.subject)); - email_create.insert( - "bodyValues".into(), - json!({ "body": { "value": draft.body, "charset": "utf-8" } }), - ); - email_create.insert( - "textBody".into(), - json!([{ "partId": "body", "type": "text/plain" }]), + + // Upload attachments and collect blob IDs + let mut uploaded_attachments: Vec = Vec::new(); + for att in draft.attachments { + let blob_id = self.upload_blob(att.data, &att.content_type).await?; + uploaded_attachments.push(UploadedAttachment { + blob_id, + filename: att.filename, + content_type: att.content_type, + }); + } + + apply_body_structure( + &mut email_create, + draft.body, + draft.html_body, + &uploaded_attachments, ); + if let Some(ref headers) = draft.threading { if !headers.in_reply_to.is_empty() { email_create.insert("inReplyTo".into(), json!(headers.in_reply_to)); @@ -790,6 +884,8 @@ impl JmapClient { bcc: ¶ms.bcc, subject, body, + html_body: params.html_body.as_deref(), + attachments: params.attachments, threading: in_reply_to.map(|id| ThreadingHeaders { in_reply_to: vec![id.to_string()], references: vec![], @@ -882,6 +978,42 @@ impl JmapClient { Ok(bytes.to_vec()) } + /// Upload a blob (for attachments) and return the blobId + #[instrument(skip(self, data))] + pub async fn upload_blob(&self, data: Vec, content_type: &str) -> Result { + let account_id = self.account_id()?; + let session = self.session()?; + + let url = session.upload_url.replace("{accountId}", account_id); + + debug!(url = %url, content_type = %content_type, size = data.len(), "Uploading blob"); + let resp = self + .client + .post(&url) + .bearer_auth(&self.token) + .header("Content-Type", content_type) + .body(data) + .send() + .await?; + + match resp.status().as_u16() { + 200..=299 => {} + 401 => return Err(Error::InvalidToken("Token expired or invalid".into())), + 429 => return Err(Error::RateLimited), + 500..=599 => return Err(Error::Server(format!("Server error: {}", resp.status()))), + code => { + let text = resp.text().await.unwrap_or_default(); + return Err(Error::Server(format!("Upload failed ({}): {}", code, text))); + } + } + + let body: Value = resp.json().await?; + body.get("blobId") + .and_then(|v| v.as_str()) + .map(String::from) + .ok_or_else(|| Error::Server("Upload response missing blobId".into())) + } + /// Send a reply to an existing email with proper threading headers #[instrument(skip(self, body, params))] pub async fn reply_email( @@ -956,6 +1088,8 @@ impl JmapClient { bcc: ¶ms.bcc, subject: &subject, body, + html_body: params.html_body.as_deref(), + attachments: params.attachments, threading: Some(ThreadingHeaders { in_reply_to: original.message_id.clone().unwrap_or_default(), references, @@ -1016,6 +1150,8 @@ impl JmapClient { bcc: ¶ms.bcc, subject: &subject, body: &full_body, + html_body: params.html_body.as_deref(), + attachments: params.attachments, threading: None, }, ) @@ -1364,4 +1500,224 @@ mod tests { assert!(err.contains("nobody@example.com")); assert!(err.contains("list identities")); } + + // ============ Body structure tests ============ + + #[test] + fn test_body_structure_plain_text_only() { + let mut email = HashMap::new(); + apply_body_structure(&mut email, "Hello world", None, &[]); + + // Should have textBody array, no htmlBody, no bodyStructure + assert!(email.contains_key("textBody")); + assert!(!email.contains_key("htmlBody")); + assert!(!email.contains_key("bodyStructure")); + + let text_body = &email["textBody"]; + assert_eq!(text_body[0]["partId"], "textBody"); + assert_eq!(text_body[0]["type"], "text/plain"); + + let body_values = &email["bodyValues"]; + assert_eq!(body_values["textBody"]["value"], "Hello world"); + assert_eq!(body_values["textBody"]["charset"], "utf-8"); + } + + #[test] + fn test_body_structure_text_plus_html() { + let mut email = HashMap::new(); + apply_body_structure(&mut email, "fallback", Some("

Rich

"), &[]); + + // Should have both textBody and htmlBody arrays, no bodyStructure + assert!(email.contains_key("textBody")); + assert!(email.contains_key("htmlBody")); + assert!(!email.contains_key("bodyStructure")); + + assert_eq!(email["textBody"][0]["partId"], "textBody"); + assert_eq!(email["htmlBody"][0]["partId"], "htmlBody"); + assert_eq!(email["htmlBody"][0]["type"], "text/html"); + + let body_values = &email["bodyValues"]; + assert_eq!(body_values["textBody"]["value"], "fallback"); + assert_eq!(body_values["htmlBody"]["value"], "

Rich

"); + } + + #[test] + fn test_body_structure_text_with_attachment() { + let mut email = HashMap::new(); + let attachments = vec![UploadedAttachment { + blob_id: "Gblob123".into(), + filename: "report.pdf".into(), + content_type: "application/pdf".into(), + }]; + apply_body_structure(&mut email, "See attached", None, &attachments); + + // Must use bodyStructure, NOT textBody/htmlBody + assert!(email.contains_key("bodyStructure")); + assert!(!email.contains_key("textBody")); + assert!(!email.contains_key("htmlBody")); + + let structure = &email["bodyStructure"]; + assert_eq!(structure["type"], "multipart/mixed"); + + let parts = structure["subParts"].as_array().unwrap(); + assert_eq!(parts.len(), 2); + + // First part: plain text + assert_eq!(parts[0]["partId"], "textBody"); + assert_eq!(parts[0]["type"], "text/plain"); + + // Second part: attachment + assert_eq!(parts[1]["blobId"], "Gblob123"); + assert_eq!(parts[1]["name"], "report.pdf"); + assert_eq!(parts[1]["type"], "application/pdf"); + assert_eq!(parts[1]["disposition"], "attachment"); + } + + #[test] + fn test_body_structure_html_with_attachment() { + let mut email = HashMap::new(); + let attachments = vec![UploadedAttachment { + blob_id: "Gblob456".into(), + filename: "_DSF1117.jpg".into(), + content_type: "image/jpeg".into(), + }]; + apply_body_structure( + &mut email, + "Fallback text", + Some("

Photo

"), + &attachments, + ); + + assert!(email.contains_key("bodyStructure")); + assert!(!email.contains_key("textBody")); + assert!(!email.contains_key("htmlBody")); + + let structure = &email["bodyStructure"]; + assert_eq!(structure["type"], "multipart/mixed"); + + let parts = structure["subParts"].as_array().unwrap(); + assert_eq!(parts.len(), 2); + + // First part: multipart/alternative with text + html + assert_eq!(parts[0]["type"], "multipart/alternative"); + let alt_parts = parts[0]["subParts"].as_array().unwrap(); + assert_eq!(alt_parts.len(), 2); + assert_eq!(alt_parts[0]["partId"], "textBody"); + assert_eq!(alt_parts[1]["partId"], "htmlBody"); + + // Second part: attachment + assert_eq!(parts[1]["blobId"], "Gblob456"); + assert_eq!(parts[1]["name"], "_DSF1117.jpg"); + + // bodyValues should have both text and html + let bv = &email["bodyValues"]; + assert_eq!(bv["textBody"]["value"], "Fallback text"); + assert_eq!(bv["htmlBody"]["value"], "

Photo

"); + } + + #[test] + fn test_body_structure_multiple_attachments() { + let mut email = HashMap::new(); + let attachments = vec![ + UploadedAttachment { + blob_id: "Ga".into(), + filename: "a.pdf".into(), + content_type: "application/pdf".into(), + }, + UploadedAttachment { + blob_id: "Gb".into(), + filename: "b.xlsx".into(), + content_type: "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + .into(), + }, + ]; + apply_body_structure(&mut email, "docs attached", None, &attachments); + + let parts = email["bodyStructure"]["subParts"].as_array().unwrap(); + assert_eq!(parts.len(), 3); // text + 2 attachments + assert_eq!(parts[1]["blobId"], "Ga"); + assert_eq!(parts[2]["blobId"], "Gb"); + } + + // ============ upload_blob mock test ============ + + #[tokio::test] + async fn test_upload_blob_success() { + use wiremock::matchers::{header, method}; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let mock_server = MockServer::start().await; + + // Mock the upload endpoint — matches what Fastmail returns + Mock::given(method("POST")) + .and(header("Content-Type", "image/jpeg")) + .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ + "accountId": "test-account", + "blobId": "G31e09448268297247a1b215a4ce1e7bc7ee05699", + "expires": "2026-04-12T15:35:44Z", + "size": 958081, + "type": "image/jpeg" + }))) + .mount(&mock_server) + .await; + + let mut client = JmapClient::new("test-token".to_string()); + let mut session = create_test_session(vec!["urn:ietf:params:jmap:core"]); + session.upload_url = format!("{}/upload/{{accountId}}/", mock_server.uri()); + client.session = Some(session); + + let blob_id = client + .upload_blob(b"fake image data".to_vec(), "image/jpeg") + .await + .unwrap(); + assert_eq!(blob_id, "G31e09448268297247a1b215a4ce1e7bc7ee05699"); + } + + #[tokio::test] + async fn test_upload_blob_413_too_large() { + use wiremock::matchers::method; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(413).set_body_string("Request Entity Too Large")) + .mount(&mock_server) + .await; + + let mut client = JmapClient::new("test-token".to_string()); + let mut session = create_test_session(vec!["urn:ietf:params:jmap:core"]); + session.upload_url = format!("{}/upload/{{accountId}}/", mock_server.uri()); + client.session = Some(session); + + let result = client + .upload_blob(b"huge file".to_vec(), "application/pdf") + .await; + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("413")); + assert!(err.contains("Too Large")); + } + + #[tokio::test] + async fn test_upload_blob_rate_limited() { + use wiremock::matchers::method; + use wiremock::{Mock, MockServer, ResponseTemplate}; + + let mock_server = MockServer::start().await; + + Mock::given(method("POST")) + .respond_with(ResponseTemplate::new(429)) + .mount(&mock_server) + .await; + + let mut client = JmapClient::new("test-token".to_string()); + let mut session = create_test_session(vec!["urn:ietf:params:jmap:core"]); + session.upload_url = format!("{}/upload/{{accountId}}/", mock_server.uri()); + client.session = Some(session); + + let result = client.upload_blob(b"data".to_vec(), "text/plain").await; + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("Rate limited")); + } } diff --git a/src/main.rs b/src/main.rs index 14e3903..fe58563 100644 --- a/src/main.rs +++ b/src/main.rs @@ -13,6 +13,31 @@ use models::Output; use std::io; use tracing_subscriber::EnvFilter; +/// Build ComposeParams with resolved HTML and loaded attachments. +fn build_compose_params<'a>( + cc: Option<&'a str>, + bcc: Option<&'a str>, + from: Option<&'a str>, + draft: bool, + html_body: Option, + html_file: Option, + attachment_paths: &[String], +) -> anyhow::Result> { + let resolved_html = util::resolve_html(html_body, html_file)?; + let attachments: Vec = attachment_paths + .iter() + .map(|p| util::load_attachment(p)) + .collect::>>()?; + Ok(jmap::ComposeParams { + cc: cc.map(util::parse_addresses).unwrap_or_default(), + bcc: bcc.map(util::parse_addresses).unwrap_or_default(), + from, + draft, + html_body: resolved_html, + attachments, + }) +} + #[derive(Parser)] #[command(name = "fastmail-cli")] #[command(version, about = "CLI for Fastmail's JMAP API", long_about = None)] @@ -145,6 +170,18 @@ enum Commands { /// Save as draft instead of sending #[arg(long)] draft: bool, + + /// HTML body content + #[arg(long, conflicts_with = "html_file")] + html_body: Option, + + /// Path to HTML file for email body + #[arg(long, conflicts_with = "html_body")] + html_file: Option, + + /// File attachment (repeatable) + #[arg(long = "attachment", short = 'a', action = clap::ArgAction::Append)] + attachments: Vec, }, /// Move email to a mailbox @@ -223,6 +260,18 @@ enum Commands { /// Save as draft instead of sending #[arg(long)] draft: bool, + + /// HTML body content + #[arg(long, conflicts_with = "html_file")] + html_body: Option, + + /// Path to HTML file for email body + #[arg(long, conflicts_with = "html_body")] + html_file: Option, + + /// File attachment (repeatable) + #[arg(long = "attachment", short = 'a', action = clap::ArgAction::Append)] + attachments: Vec, }, /// Forward an email @@ -253,6 +302,18 @@ enum Commands { /// Save as draft instead of sending #[arg(long)] draft: bool, + + /// HTML body content + #[arg(long, conflicts_with = "html_file")] + html_body: Option, + + /// Path to HTML file for email body + #[arg(long, conflicts_with = "html_body")] + html_file: Option, + + /// File attachment (repeatable) + #[arg(long = "attachment", short = 'a', action = clap::ArgAction::Append)] + attachments: Vec, }, /// Generate shell completions @@ -421,22 +482,22 @@ async fn main() { reply_to, from, draft, + html_body, + html_file, + attachments, } => { - commands::send( - &to, - &subject, - &body, - reply_to.as_deref(), - jmap::ComposeParams { - cc: cc.as_deref().map(util::parse_addresses).unwrap_or_default(), - bcc: bcc - .as_deref() - .map(util::parse_addresses) - .unwrap_or_default(), - from: from.as_deref(), + async { + let params = build_compose_params( + cc.as_deref(), + bcc.as_deref(), + from.as_deref(), draft, - }, - ) + html_body, + html_file, + &attachments, + )?; + commands::send(&to, &subject, &body, reply_to.as_deref(), params).await + } .await } @@ -475,21 +536,22 @@ async fn main() { bcc, from, draft, + html_body, + html_file, + attachments, } => { - commands::reply( - &email_id, - &body, - all, - jmap::ComposeParams { - cc: cc.as_deref().map(util::parse_addresses).unwrap_or_default(), - bcc: bcc - .as_deref() - .map(util::parse_addresses) - .unwrap_or_default(), - from: from.as_deref(), + async { + let params = build_compose_params( + cc.as_deref(), + bcc.as_deref(), + from.as_deref(), draft, - }, - ) + html_body, + html_file, + &attachments, + )?; + commands::reply(&email_id, &body, all, params).await + } .await } @@ -501,21 +563,22 @@ async fn main() { bcc, from, draft, + html_body, + html_file, + attachments, } => { - commands::forward( - &email_id, - &to, - &body, - jmap::ComposeParams { - cc: cc.as_deref().map(util::parse_addresses).unwrap_or_default(), - bcc: bcc - .as_deref() - .map(util::parse_addresses) - .unwrap_or_default(), - from: from.as_deref(), + async { + let params = build_compose_params( + cc.as_deref(), + bcc.as_deref(), + from.as_deref(), draft, - }, - ) + html_body, + html_file, + &attachments, + )?; + commands::forward(&email_id, &to, &body, params).await + } .await } diff --git a/src/mcp/graphql/mutation.rs b/src/mcp/graphql/mutation.rs index 0090e68..bbfca7c 100644 --- a/src/mcp/graphql/mutation.rs +++ b/src/mcp/graphql/mutation.rs @@ -24,6 +24,9 @@ impl MutationRoot { #[graphql(desc = "CC recipients, comma-separated")] cc: Option, #[graphql(desc = "BCC recipients (hidden), comma-separated")] bcc: Option, #[graphql(desc = "Send from a specific identity/email address")] from: Option, + #[graphql(desc = "HTML body content (alternative to plain text)")] html_body: Option< + String, + >, #[graphql(desc = "Token from PREVIEW response — required for CONFIRM/DRAFT")] confirmation_token: Option, ) -> Result { @@ -33,12 +36,15 @@ impl MutationRoot { let token = super::types::confirmation_token(&[&to, &subject, &body]); if matches!(action, SendAction::Preview) { + let mut preview = + format_send_preview(&to_addrs, &cc_addrs, &bcc_addrs, &subject, &body); + if html_body.is_some() { + preview.push_str("\n[HTML body included]"); + } return Ok(GqlComposeResult { success: true, email_id: None, - preview: Some(format_send_preview( - &to_addrs, &cc_addrs, &bcc_addrs, &subject, &body, - )), + preview: Some(preview), confirmation_token: Some(token), error: None, }); @@ -72,6 +78,8 @@ impl MutationRoot { bcc: bcc_addrs, from: from.as_deref(), draft, + html_body, + attachments: vec![], }, ) .await @@ -105,6 +113,9 @@ impl MutationRoot { #[graphql(desc = "CC recipients, comma-separated")] cc: Option, #[graphql(desc = "BCC recipients, comma-separated")] bcc: Option, #[graphql(desc = "Send from a specific identity/email address")] from: Option, + #[graphql(desc = "HTML body content (alternative to plain text)")] html_body: Option< + String, + >, #[graphql(desc = "Token from PREVIEW response — required for CONFIRM/DRAFT")] confirmation_token: Option, ) -> Result { @@ -136,26 +147,30 @@ impl MutationRoot { .and_then(|v| v.first()) .cloned() .unwrap_or_else(|| "(none)".to_string()); + let mut preview = format!( + "REPLY PREVIEW:\nTo: {}\nCC: {}\nBCC: {}\nSubject: {}\nIn-Reply-To: {}\n\n--- Your Reply ---\n{}", + format_addrs(&to_addrs), + if cc_addrs.is_empty() { + "(none)".to_string() + } else { + format_addrs(&cc_addrs) + }, + if bcc_addrs.is_empty() { + "(none)".to_string() + } else { + format_addrs(&bcc_addrs) + }, + subject, + in_reply_to, + body + ); + if html_body.is_some() { + preview.push_str("\n[HTML body included]"); + } return Ok(GqlComposeResult { success: true, email_id: None, - preview: Some(format!( - "REPLY PREVIEW:\nTo: {}\nCC: {}\nBCC: {}\nSubject: {}\nIn-Reply-To: {}\n\n--- Your Reply ---\n{}", - format_addrs(&to_addrs), - if cc_addrs.is_empty() { - "(none)".to_string() - } else { - format_addrs(&cc_addrs) - }, - if bcc_addrs.is_empty() { - "(none)".to_string() - } else { - format_addrs(&bcc_addrs) - }, - subject, - in_reply_to, - body - )), + preview: Some(preview), confirmation_token: Some(token), error: None, }); @@ -184,6 +199,8 @@ impl MutationRoot { bcc: bcc_addrs, from: from.as_deref(), draft, + html_body, + attachments: vec![], }, ) .await @@ -217,6 +234,9 @@ impl MutationRoot { #[graphql(desc = "CC recipients, comma-separated")] cc: Option, #[graphql(desc = "BCC recipients, comma-separated")] bcc: Option, #[graphql(desc = "Send from a specific identity/email address")] from: Option, + #[graphql(desc = "HTML body content (alternative to plain text)")] html_body: Option< + String, + >, #[graphql(desc = "Token from PREVIEW response — required for CONFIRM/DRAFT")] confirmation_token: Option, ) -> Result { @@ -244,30 +264,34 @@ impl MutationRoot { let original_body = original.text_content().unwrap_or(""); let sender = format_addrs(&original.from.clone().unwrap_or_default()); + let mut preview = format!( + "FORWARD PREVIEW:\nTo: {}\nCC: {}\nBCC: {}\nSubject: {}\nForwarding from: {}\n\n--- Your Message ---\n{}\n\n--- Forwarded ---\nFrom: {}\nDate: {}\nSubject: {}\n\n{}", + format_addrs(&to_addrs), + if cc_addrs.is_empty() { + "(none)".to_string() + } else { + format_addrs(&cc_addrs) + }, + if bcc_addrs.is_empty() { + "(none)".to_string() + } else { + format_addrs(&bcc_addrs) + }, + subject, + sender, + body_str, + sender, + original.received_at.as_deref().unwrap_or("unknown"), + original.subject.as_deref().unwrap_or(""), + original_body, + ); + if html_body.is_some() { + preview.push_str("\n[HTML body included]"); + } return Ok(GqlComposeResult { success: true, email_id: None, - preview: Some(format!( - "FORWARD PREVIEW:\nTo: {}\nCC: {}\nBCC: {}\nSubject: {}\nForwarding from: {}\n\n--- Your Message ---\n{}\n\n--- Forwarded ---\nFrom: {}\nDate: {}\nSubject: {}\n\n{}", - format_addrs(&to_addrs), - if cc_addrs.is_empty() { - "(none)".to_string() - } else { - format_addrs(&cc_addrs) - }, - if bcc_addrs.is_empty() { - "(none)".to_string() - } else { - format_addrs(&bcc_addrs) - }, - subject, - sender, - body_str, - sender, - original.received_at.as_deref().unwrap_or("unknown"), - original.subject.as_deref().unwrap_or(""), - original_body, - )), + preview: Some(preview), confirmation_token: Some(token), error: None, }); @@ -296,6 +320,8 @@ impl MutationRoot { bcc: bcc_addrs, from: from.as_deref(), draft, + html_body, + attachments: vec![], }, ) .await diff --git a/src/util.rs b/src/util.rs index fa0c49b..bd680b3 100644 --- a/src/util.rs +++ b/src/util.rs @@ -1,3 +1,4 @@ +use crate::jmap::AttachmentData; use crate::models::EmailAddress; use std::path::Path; @@ -76,7 +77,7 @@ fn is_image_extension(filename: &str) -> bool { } /// Infer MIME type from filename extension for documents -fn mime_from_filename(filename: &str) -> String { +pub fn mime_from_filename(filename: &str) -> String { let ext = Path::new(filename) .extension() .and_then(|e| e.to_str()) @@ -252,9 +253,100 @@ pub fn resize_image( Ok((output, "image/jpeg".to_string())) } +/// Load a file from disk as an attachment, inferring MIME type from extension. +pub fn load_attachment(path: &str) -> anyhow::Result { + let p = Path::new(path); + let filename = p + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("attachment") + .to_string(); + let content_type = mime_from_filename(&filename); + let data = std::fs::read(p) + .map_err(|e| anyhow::anyhow!("Failed to read attachment '{}': {}", path, e))?; + Ok(AttachmentData { + filename, + content_type, + data, + }) +} + +/// Resolve HTML body from either inline string or file path. +pub fn resolve_html( + html_body: Option, + html_file: Option, +) -> anyhow::Result> { + if let Some(html) = html_body { + return Ok(Some(html)); + } + if let Some(path) = html_file { + let content = std::fs::read_to_string(&path) + .map_err(|e| anyhow::anyhow!("Failed to read HTML file '{}': {}", path, e))?; + return Ok(Some(content)); + } + Ok(None) +} + #[cfg(test)] mod tests { use super::*; + use std::io::Write; + + #[test] + fn test_resolve_html_inline() { + let result = resolve_html(Some("

Hi

".into()), None).unwrap(); + assert_eq!(result, Some("

Hi

".into())); + } + + #[test] + fn test_resolve_html_file() { + let mut tmp = tempfile::NamedTempFile::new().unwrap(); + write!(tmp, "

from file

").unwrap(); + let result = resolve_html(None, Some(tmp.path().to_str().unwrap().into())).unwrap(); + assert_eq!(result, Some("

from file

".into())); + } + + #[test] + fn test_resolve_html_none() { + let result = resolve_html(None, None).unwrap(); + assert_eq!(result, None); + } + + #[test] + fn test_resolve_html_missing_file() { + let result = resolve_html(None, Some("/nonexistent/file.html".into())); + assert!(result.is_err()); + } + + #[test] + fn test_load_attachment_success() { + let mut tmp = tempfile::Builder::new().suffix(".pdf").tempfile().unwrap(); + write!(tmp, "fake pdf").unwrap(); + let att = load_attachment(tmp.path().to_str().unwrap()).unwrap(); + assert_eq!( + att.filename, + tmp.path().file_name().unwrap().to_str().unwrap() + ); + assert_eq!(att.content_type, "application/pdf"); + assert_eq!(att.data, b"fake pdf"); + } + + #[test] + fn test_load_attachment_missing_file() { + let result = load_attachment("/nonexistent/file.txt"); + assert!(result.is_err()); + } + + #[test] + fn test_load_attachment_mime_inference() { + let mut tmp = tempfile::Builder::new().suffix(".xlsx").tempfile().unwrap(); + write!(tmp, "data").unwrap(); + let att = load_attachment(tmp.path().to_str().unwrap()).unwrap(); + assert_eq!( + att.content_type, + "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet" + ); + } #[test] fn test_parse_single_email() {