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