//! JMAP Email operations. use chrono::{DateTime, Utc}; use super::client::JmapClient; use super::types::{EmailFilter, JmapEmail, JmapRequest, SortCondition}; use serde_json::json; /// Parsed email for storage (similar to IMAP ParsedEmail). #[derive(Debug, Clone)] pub struct JmapParsedEmail { /// JMAP email ID pub jmap_id: String, /// Message-ID header pub message_id: Option, /// In-Reply-To header pub in_reply_to: Option, /// First entry from the References header (thread root). pub references_root: Option, /// Source mailbox name pub source_folder: String, /// From address pub from: String, /// To address pub to: String, /// Subject pub subject: String, /// Body text pub body: String, /// Received date pub date: DateTime, /// Whether email has been read pub is_read: bool, } impl JmapClient { /// Fetches emails from a mailbox. /// /// # Arguments /// * `mailbox_id` - The mailbox ID (use `inbox().await?.id` for inbox) /// * `since` - Optional date filter for incremental sync /// * `limit` - Maximum number of emails to fetch pub async fn fetch_emails( &mut self, mailbox_id: &str, since: Option>, limit: u32, ) -> Result, String> { let account_id = self.account_id().await?; // Build filter let mut filter = EmailFilter { in_mailbox: Some(mailbox_id.to_string()), ..Default::default() }; if let Some(after) = since { filter.after = Some(after); } // Query for email IDs let query_args = json!({ "accountId": account_id, "filter": filter, "sort": [SortCondition::received_desc()], "position": 0, "limit": limit, "calculateTotal": false }); let query_response = self.call("Email/query", query_args).await?; let email_ids: Vec = serde_json::from_value(query_response.data["ids"].clone()) .map_err(|e| format!("Failed to parse email IDs: {}", e))?; if email_ids.is_empty() { return Ok(Vec::new()); } // Fetch email details let get_args = json!({ "accountId": account_id, "ids": email_ids, "properties": [ "id", "threadId", "mailboxIds", "keywords", "receivedAt", "messageId", "inReplyTo", "references", "from", "to", "subject", "preview", "bodyValues", "textBody" ], "fetchTextBodyValues": true, "maxBodyValueBytes": 100000 }); let get_response = self.call("Email/get", get_args).await?; let emails: Vec = serde_json::from_value(get_response.data["list"].clone()) .map_err(|e| format!("Failed to parse emails: {}", e))?; // Get mailbox name let mailbox = self.list_mailboxes().await? .into_iter() .find(|m| m.id == mailbox_id) .map(|m| m.name) .unwrap_or_else(|| "Unknown".to_string()); // Convert to parsed emails let mut parsed = Vec::new(); for email in emails { let from = email .from .as_ref() .and_then(|addrs| addrs.first()) .map(|a| a.to_string()) .unwrap_or_default(); let to = email .to .as_ref() .and_then(|addrs| addrs.first()) .map(|a| a.to_string()) .unwrap_or_default(); let body = Self::extract_body(&email); let is_read = email .keywords .as_ref() .map(|k| k.contains_key("$seen")) .unwrap_or(false); let message_id = email .message_id .as_ref() .and_then(|ids| ids.first()) .cloned(); let in_reply_to = email .in_reply_to .as_ref() .and_then(|ids| ids.first()) .cloned(); let references_root = email .references .as_ref() .and_then(|refs| refs.first()) .cloned(); parsed.push(JmapParsedEmail { jmap_id: email.id, message_id, in_reply_to, references_root, source_folder: mailbox.clone(), from, to, subject: email.subject.unwrap_or_default(), body, date: email.received_at.unwrap_or_else(Utc::now), is_read, }); } Ok(parsed) } /// Fetches emails from inbox. pub async fn fetch_inbox( &mut self, since: Option>, limit: u32, ) -> Result, String> { let inbox = self.inbox().await?; self.fetch_emails(&inbox.id, since, limit).await } /// Fetches emails from archive. pub async fn fetch_archive( &mut self, since: Option>, limit: u32, ) -> Result, String> { let archive = self.archive_mailbox().await?; self.fetch_emails(&archive.id, since, limit).await } /// Marks an email as read. pub async fn mark_read(&mut self, email_id: &str) -> Result<(), String> { self.set_keyword(email_id, "$seen", true).await } /// Marks an email as unread. pub async fn mark_unread(&mut self, email_id: &str) -> Result<(), String> { self.set_keyword(email_id, "$seen", false).await } /// Sets or removes a keyword on an email. async fn set_keyword(&mut self, email_id: &str, keyword: &str, set: bool) -> Result<(), String> { let account_id = self.account_id().await?; let keyword_path = format!("keywords/{}", keyword); let mut email_patch = serde_json::Map::new(); email_patch.insert(keyword_path, if set { json!(true) } else { json!(null) }); let mut update_map = serde_json::Map::new(); update_map.insert(email_id.to_string(), json!(email_patch)); let update_args = json!({ "accountId": account_id, "update": update_map }); let response = self.call("Email/set", update_args).await?; if let Some(not_updated) = response.data["notUpdated"].as_object() { if let Some(error) = not_updated.get(email_id) { let error_type = error["type"].as_str().unwrap_or("unknown"); let description = error["description"].as_str().unwrap_or("Unknown error"); return Err(format!("Failed to update email ({}): {}", error_type, description)); } } Ok(()) } /// Sends an email via JMAP Submission. pub async fn send_email( &mut self, to: &str, subject: &str, body: &str, ) -> Result { let account_id = self.account_id().await?; let username = self.username().await?; // Get identity ID (usually matches the account) let identity_response = self.call("Identity/get", json!({ "accountId": account_id, "ids": null })).await?; let identities: Vec = serde_json::from_value(identity_response.data["list"].clone()) .map_err(|e| format!("Failed to parse identities: {}", e))?; let identity_id = identities .first() .and_then(|i| i["id"].as_str()) .ok_or_else(|| "No identity found".to_string())? .to_string(); // Create the email let sent_mailbox = self.sent_mailbox().await?; let email_create_id = "email_create"; let mut request = JmapRequest::new(); // Email/set to create the email let mut mailbox_ids = serde_json::Map::new(); mailbox_ids.insert(sent_mailbox.id.clone(), json!(true)); let mut create_map = serde_json::Map::new(); create_map.insert(email_create_id.to_string(), json!({ "mailboxIds": mailbox_ids, "from": [{ "email": username }], "to": [{ "email": to }], "subject": subject, "textBody": [{ "partId": "body", "type": "text/plain" }], "bodyValues": { "body": { "value": body } } })); request.add_call( "Email/set", json!({ "accountId": account_id, "create": create_map }), "0", ); // EmailSubmission/set to send it let email_ref = format!("#{}", email_create_id); let mut on_success = serde_json::Map::new(); on_success.insert(email_ref.clone(), json!({ "keywords/$draft": null })); request.add_call( "EmailSubmission/set", json!({ "accountId": account_id, "create": { "send": { "identityId": identity_id, "emailId": email_ref } }, "onSuccessUpdateEmail": on_success }), "1", ); let response = self.execute(request).await?; // Extract the created email ID for method_response in &response.method_responses { if method_response.method == "Email/set" { if let Some(created) = method_response.data["created"].as_object() { if let Some(email) = created.get(email_create_id) { if let Some(id) = email["id"].as_str() { return Ok(id.to_string()); } } } if let Some(not_created) = method_response.data["notCreated"].as_object() { if let Some(error) = not_created.get(email_create_id) { let error_type = error["type"].as_str().unwrap_or("unknown"); let description = error["description"].as_str().unwrap_or("Unknown error"); return Err(format!("Failed to create email ({}): {}", error_type, description)); } } } } Err("Failed to send email: no response".to_string()) } /// Extracts the text body from a JMAP email. fn extract_body(email: &JmapEmail) -> String { // First try to get from bodyValues using textBody part IDs if let (Some(body_values), Some(text_body)) = (&email.body_values, &email.text_body) { for part in text_body { if let Some(part_id) = &part.part_id { if let Some(body_value) = body_values.get(part_id) { return body_value.value.clone(); } } } } // Fall back to preview email.preview.clone().unwrap_or_default() } } /// Tests JMAP connection by fetching session info. pub async fn test_connection(session_url: &str, access_token: &str) -> Result { let mut client = JmapClient::new(session_url, access_token)?; let session = client.session().await?; Ok(format!( "Connected as: {} (account: {})", session.username, session.primary_email_account().unwrap_or("unknown") )) } #[cfg(test)] mod tests { use super::*; use crate::jmap::types::*; use serde_json::json; // Helper to build a JmapEmail from JSON (leveraging serde) fn email_from_json(value: serde_json::Value) -> JmapEmail { serde_json::from_value(value).unwrap() } // ---- extract_body tests ---- #[test] fn extract_body_from_body_values_and_text_body() { let email = email_from_json(json!({ "id": "e1", "bodyValues": { "1": {"value": "Hello, this is the full body text."} }, "textBody": [{"partId": "1", "type": "text/plain"}] })); let body = JmapClient::extract_body(&email); assert_eq!(body, "Hello, this is the full body text."); } #[test] fn extract_body_multiple_parts_uses_first_match() { let email = email_from_json(json!({ "id": "e2", "bodyValues": { "1": {"value": "Part 1 text"}, "2": {"value": "Part 2 text"} }, "textBody": [ {"partId": "1", "type": "text/plain"}, {"partId": "2", "type": "text/plain"} ] })); let body = JmapClient::extract_body(&email); assert_eq!(body, "Part 1 text"); } #[test] fn extract_body_falls_back_to_preview() { let email = email_from_json(json!({ "id": "e3", "preview": "This is the preview text..." })); let body = JmapClient::extract_body(&email); assert_eq!(body, "This is the preview text..."); } #[test] fn extract_body_empty_when_no_body_and_no_preview() { let email = email_from_json(json!({ "id": "e4" })); let body = JmapClient::extract_body(&email); assert_eq!(body, ""); } #[test] fn extract_body_with_body_values_but_no_text_body() { // bodyValues exist but textBody is missing -- should fall back to preview let email = email_from_json(json!({ "id": "e5", "bodyValues": { "1": {"value": "Orphaned body value"} }, "preview": "Fallback preview" })); let body = JmapClient::extract_body(&email); assert_eq!(body, "Fallback preview"); } #[test] fn extract_body_with_text_body_but_no_body_values() { // textBody exists but bodyValues is missing -- should fall back to preview let email = email_from_json(json!({ "id": "e6", "textBody": [{"partId": "1", "type": "text/plain"}], "preview": "Fallback preview" })); let body = JmapClient::extract_body(&email); assert_eq!(body, "Fallback preview"); } #[test] fn extract_body_part_id_not_in_body_values() { // textBody references a part ID that doesn't exist in bodyValues let email = email_from_json(json!({ "id": "e7", "bodyValues": { "99": {"value": "Wrong part"} }, "textBody": [{"partId": "1", "type": "text/plain"}], "preview": "Fallback" })); let body = JmapClient::extract_body(&email); assert_eq!(body, "Fallback"); } #[test] fn extract_body_text_body_without_part_id() { let email = email_from_json(json!({ "id": "e8", "bodyValues": { "1": {"value": "Body text"} }, "textBody": [{"type": "text/plain"}], "preview": "Preview text" })); let body = JmapClient::extract_body(&email); // No partId on the text body part, so can't look up in bodyValues assert_eq!(body, "Preview text"); } #[test] fn extract_body_empty_body_value() { let email = email_from_json(json!({ "id": "e9", "bodyValues": { "1": {"value": ""} }, "textBody": [{"partId": "1", "type": "text/plain"}] })); let body = JmapClient::extract_body(&email); // Returns empty string from bodyValues (not falling through to preview) assert_eq!(body, ""); } #[test] fn extract_body_multipart_first_has_no_part_id_second_does() { let email = email_from_json(json!({ "id": "e10", "bodyValues": { "2": {"value": "Second part body"} }, "textBody": [ {"type": "text/plain"}, {"partId": "2", "type": "text/plain"} ] })); let body = JmapClient::extract_body(&email); // First part has no partId, skipped; second part matches assert_eq!(body, "Second part body"); } // ---- JmapParsedEmail construction ---- #[test] fn jmap_parsed_email_fields() { let parsed = JmapParsedEmail { jmap_id: "jmap_1".to_string(), message_id: Some("".to_string()), in_reply_to: None, references_root: None, source_folder: "Inbox".to_string(), from: "Alice ".to_string(), to: "bob@example.com".to_string(), subject: "Test Subject".to_string(), body: "Test body".to_string(), date: chrono::Utc::now(), is_read: false, }; assert_eq!(parsed.jmap_id, "jmap_1"); assert_eq!(parsed.source_folder, "Inbox"); assert!(!parsed.is_read); } // ---- Email deserialization for read/unread detection ---- #[test] fn email_is_read_when_seen_keyword_present() { let email = email_from_json(json!({ "id": "e_read", "keywords": {"$seen": true} })); let is_read = email .keywords .as_ref() .map(|k| k.contains_key("$seen")) .unwrap_or(false); assert!(is_read); } #[test] fn email_is_unread_when_keywords_empty() { let email = email_from_json(json!({ "id": "e_unread", "keywords": {} })); let is_read = email .keywords .as_ref() .map(|k| k.contains_key("$seen")) .unwrap_or(false); assert!(!is_read); } #[test] fn email_is_unread_when_keywords_missing() { let email = email_from_json(json!({ "id": "e_no_kw" })); let is_read = email .keywords .as_ref() .map(|k| k.contains_key("$seen")) .unwrap_or(false); assert!(!is_read); } // ---- Address extraction patterns ---- #[test] fn from_address_extraction_with_name() { let email = email_from_json(json!({ "id": "e_addr1", "from": [{"name": "Alice Smith", "email": "alice@example.com"}] })); let from = email .from .as_ref() .and_then(|addrs| addrs.first()) .map(|a| a.to_string()) .unwrap_or_default(); assert_eq!(from, "Alice Smith "); } #[test] fn from_address_extraction_without_name() { let email = email_from_json(json!({ "id": "e_addr2", "from": [{"email": "noreply@example.com"}] })); let from = email .from .as_ref() .and_then(|addrs| addrs.first()) .map(|a| a.to_string()) .unwrap_or_default(); assert_eq!(from, "noreply@example.com"); } #[test] fn from_address_extraction_empty_list() { let email = email_from_json(json!({ "id": "e_addr3", "from": [] })); let from = email .from .as_ref() .and_then(|addrs| addrs.first()) .map(|a| a.to_string()) .unwrap_or_default(); assert_eq!(from, ""); } #[test] fn from_address_extraction_missing() { let email = email_from_json(json!({ "id": "e_addr4" })); let from = email .from .as_ref() .and_then(|addrs| addrs.first()) .map(|a| a.to_string()) .unwrap_or_default(); assert_eq!(from, ""); } // ---- Message-ID extraction ---- #[test] fn message_id_extraction() { let email = email_from_json(json!({ "id": "e_mid", "messageId": ["", ""] })); let message_id = email .message_id .as_ref() .and_then(|ids| ids.first()) .cloned(); assert_eq!(message_id, Some("".to_string())); } #[test] fn in_reply_to_extraction() { let email = email_from_json(json!({ "id": "e_irt", "inReplyTo": [""] })); let in_reply_to = email .in_reply_to .as_ref() .and_then(|ids| ids.first()) .cloned(); assert_eq!(in_reply_to, Some("".to_string())); } }