//! Gmail API v1 implementation. //! //! All API calls go through the host's HTTP capability, which handles //! credential injection and rate limiting. The WASM tool never sees //! the actual OAuth token. use crate::near::agent::host; use crate::types::*; const GMAIL_API_BASE: &str = "https://gmail.googleapis.com/gmail/v1/users/me"; /// Make a Gmail API call. fn api_call(method: &str, path: &str, body: Option<&str>) -> Result { let url = format!("{}/{}", GMAIL_API_BASE, path); let headers = if body.is_some() { r#"{"Content-Type": "application/json"}"# } else { "{}" }; let body_bytes = body.map(|b| b.as_bytes().to_vec()); host::log( host::LogLevel::Debug, &format!("Gmail API: {} {}", method, path), ); let response = host::http_request(method, &url, headers, body_bytes.as_deref(), None)?; if response.status < 200 || response.status >= 300 { let body_text = String::from_utf8_lossy(&response.body); return Err(format!( "Gmail API returned status {}: {}", response.status, body_text )); } if response.body.is_empty() { return Ok(String::new()); } String::from_utf8(response.body).map_err(|e| format!("Invalid UTF-8 in response: {}", e)) } /// Extract a header value from a Gmail message payload. fn get_header(payload: &serde_json::Value, name: &str) -> String { payload["headers"] .as_array() .and_then(|headers| { headers.iter().find(|h| { h["name"] .as_str() .map(|n| n.eq_ignore_ascii_case(name)) .unwrap_or(false) }) }) .and_then(|h| h["value"].as_str()) .unwrap_or("") .to_string() } /// Extract plain text body from a Gmail message payload. /// Walks the MIME parts tree to find text/plain content. fn extract_body(payload: &serde_json::Value) -> String { // Try direct body first (simple messages) if let Some(data) = payload["body"]["data"].as_str() { if let Some(decoded) = base64url_decode(data) { return decoded; } } // Walk parts for multipart messages if let Some(parts) = payload["parts"].as_array() { for part in parts { let mime_type = part["mimeType"].as_str().unwrap_or(""); if mime_type == "text/plain" { if let Some(data) = part["body"]["data"].as_str() { if let Some(decoded) = base64url_decode(data) { return decoded; } } } // Recurse into nested parts (e.g., multipart/alternative inside multipart/mixed) if mime_type.starts_with("multipart/") { let nested = extract_body(part); if !nested.is_empty() { return nested; } } } // Fall back to text/html if no text/plain found for part in parts { if part["mimeType"].as_str() == Some("text/html") { if let Some(data) = part["body"]["data"].as_str() { if let Some(decoded) = base64url_decode(data) { return decoded; } } } } } String::new() } /// Parse a full message from the API response. fn parse_message(v: &serde_json::Value) -> Message { let payload = &v["payload"]; let label_ids: Vec = v["labelIds"] .as_array() .map(|arr| { arr.iter() .filter_map(|l| l.as_str().map(|s| s.to_string())) .collect() }) .unwrap_or_default(); Message { id: v["id"].as_str().unwrap_or("").to_string(), thread_id: v["threadId"].as_str().unwrap_or("").to_string(), subject: get_header(payload, "Subject"), from: get_header(payload, "From"), to: get_header(payload, "To"), cc: { let cc = get_header(payload, "Cc"); if cc.is_empty() { None } else { Some(cc) } }, date: get_header(payload, "Date"), body: extract_body(payload), snippet: v["snippet"].as_str().unwrap_or("").to_string(), is_unread: label_ids.iter().any(|l| l == "UNREAD"), label_ids, } } /// List messages in the mailbox. pub fn list_messages( query: Option<&str>, max_results: u32, label_ids: &[String], ) -> Result { let mut params = vec![format!("maxResults={}", max_results)]; if let Some(q) = query { params.push(format!("q={}", url_encode(q))); } for label in label_ids { params.push(format!("labelIds={}", url_encode(label))); } let path = format!("messages?{}", params.join("&")); let response = api_call("GET", &path, None)?; let parsed: serde_json::Value = serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; let result_size_estimate = parsed["resultSizeEstimate"].as_u64().unwrap_or(0) as u32; // The list endpoint only returns message IDs and thread IDs. // We need to fetch each message to get summaries. let message_ids: Vec = parsed["messages"] .as_array() .map(|arr| { arr.iter() .filter_map(|m| m["id"].as_str().map(|s| s.to_string())) .collect() }) .unwrap_or_default(); let mut messages = Vec::new(); for id in &message_ids { // Fetch metadata format (lighter than full) for list view let msg_path = format!("messages/{}?format=metadata", url_encode(id)); if let Ok(msg_response) = api_call("GET", &msg_path, None) { if let Ok(msg) = serde_json::from_str::(&msg_response) { let payload = &msg["payload"]; let label_ids: Vec = msg["labelIds"] .as_array() .map(|arr| { arr.iter() .filter_map(|l| l.as_str().map(|s| s.to_string())) .collect() }) .unwrap_or_default(); messages.push(MessageSummary { id: msg["id"].as_str().unwrap_or("").to_string(), thread_id: msg["threadId"].as_str().unwrap_or("").to_string(), subject: get_header(payload, "Subject"), from: get_header(payload, "From"), to: get_header(payload, "To"), date: get_header(payload, "Date"), snippet: msg["snippet"].as_str().unwrap_or("").to_string(), is_unread: label_ids.iter().any(|l| l == "UNREAD"), label_ids, }); } } } Ok(ListMessagesResult { messages, result_size_estimate, next_page_token: parsed["nextPageToken"].as_str().map(|s| s.to_string()), }) } /// Get a specific message with full content. pub fn get_message(message_id: &str) -> Result { let path = format!("messages/{}?format=full", url_encode(message_id)); let response = api_call("GET", &path, None)?; let parsed: serde_json::Value = serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; Ok(parse_message(&parsed)) } /// Build an RFC 2822 email and base64url-encode it. fn build_raw_email( to: &str, subject: &str, body: &str, cc: Option<&str>, bcc: Option<&str>, in_reply_to: Option<&str>, references: Option<&str>, ) -> String { let mut email = String::new(); email.push_str(&format!("To: {}\r\n", to)); email.push_str(&format!("Subject: {}\r\n", subject)); email.push_str("Content-Type: text/plain; charset=\"UTF-8\"\r\n"); email.push_str("MIME-Version: 1.0\r\n"); if let Some(cc_val) = cc { email.push_str(&format!("Cc: {}\r\n", cc_val)); } if let Some(bcc_val) = bcc { email.push_str(&format!("Bcc: {}\r\n", bcc_val)); } if let Some(irt) = in_reply_to { email.push_str(&format!("In-Reply-To: {}\r\n", irt)); } if let Some(refs) = references { email.push_str(&format!("References: {}\r\n", refs)); } email.push_str("\r\n"); email.push_str(body); base64url_encode(email.as_bytes()) } /// Send an email. pub fn send_message( to: &str, subject: &str, body: &str, cc: Option<&str>, bcc: Option<&str>, ) -> Result { let raw = build_raw_email(to, subject, body, cc, bcc, None, None); let payload = serde_json::json!({ "raw": raw }); let body_str = serde_json::to_string(&payload).map_err(|e| e.to_string())?; let response = api_call("POST", "messages/send", Some(&body_str))?; let parsed: serde_json::Value = serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; Ok(SendResult { id: parsed["id"].as_str().unwrap_or("").to_string(), thread_id: parsed["threadId"].as_str().unwrap_or("").to_string(), label_ids: parsed["labelIds"] .as_array() .map(|arr| { arr.iter() .filter_map(|l| l.as_str().map(|s| s.to_string())) .collect() }) .unwrap_or_default(), }) } /// Create a draft email. pub fn create_draft( to: &str, subject: &str, body: &str, cc: Option<&str>, bcc: Option<&str>, ) -> Result { let raw = build_raw_email(to, subject, body, cc, bcc, None, None); let payload = serde_json::json!({ "message": { "raw": raw } }); let body_str = serde_json::to_string(&payload).map_err(|e| e.to_string())?; let response = api_call("POST", "drafts", Some(&body_str))?; let parsed: serde_json::Value = serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; Ok(DraftResult { id: parsed["id"].as_str().unwrap_or("").to_string(), message_id: parsed["message"]["id"].as_str().unwrap_or("").to_string(), }) } /// Reply to an existing message. pub fn reply_to_message( message_id: &str, body: &str, reply_all: bool, ) -> Result { // First, get the original message to extract headers let original = get_message(message_id)?; let to = if reply_all { // Combine From and To (excluding self, but we don't know self here, // so include all and let Gmail dedupe) let mut recipients = original.from.clone(); if !original.to.is_empty() { recipients.push_str(", "); recipients.push_str(&original.to); } if let Some(ref cc) = original.cc { recipients.push_str(", "); recipients.push_str(cc); } recipients } else { original.from.clone() }; let subject = if original.subject.to_lowercase().starts_with("re:") { original.subject.clone() } else { format!("Re: {}", original.subject) }; // Build Message-ID reference for threading. // The original message_id from Gmail is not the RFC 2822 Message-ID header, // so we use the thread_id to keep the thread together. let raw = build_raw_email(&to, &subject, body, None, None, None, None); let payload = serde_json::json!({ "raw": raw, "threadId": original.thread_id }); let body_str = serde_json::to_string(&payload).map_err(|e| e.to_string())?; let response = api_call("POST", "messages/send", Some(&body_str))?; let parsed: serde_json::Value = serde_json::from_str(&response).map_err(|e| format!("Failed to parse response: {}", e))?; Ok(SendResult { id: parsed["id"].as_str().unwrap_or("").to_string(), thread_id: parsed["threadId"].as_str().unwrap_or("").to_string(), label_ids: parsed["labelIds"] .as_array() .map(|arr| { arr.iter() .filter_map(|l| l.as_str().map(|s| s.to_string())) .collect() }) .unwrap_or_default(), }) } /// Move a message to trash. pub fn trash_message(message_id: &str) -> Result { let path = format!("messages/{}/trash", url_encode(message_id)); api_call("POST", &path, None)?; Ok(TrashResult { id: message_id.to_string(), trashed: true, }) } // ==================== Encoding Utilities ==================== const BASE64URL_CHARS: &[u8; 64] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_"; /// Base64url-encode bytes (no padding, URL-safe alphabet). fn base64url_encode(input: &[u8]) -> String { let mut result = String::with_capacity(input.len().div_ceil(3) * 4); for chunk in input.chunks(3) { let b0 = chunk[0] as u32; let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 }; let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 }; let triple = (b0 << 16) | (b1 << 8) | b2; result.push(BASE64URL_CHARS[((triple >> 18) & 0x3F) as usize] as char); result.push(BASE64URL_CHARS[((triple >> 12) & 0x3F) as usize] as char); if chunk.len() > 1 { result.push(BASE64URL_CHARS[((triple >> 6) & 0x3F) as usize] as char); } if chunk.len() > 2 { result.push(BASE64URL_CHARS[(triple & 0x3F) as usize] as char); } } result } /// Base64url-decode a string. Returns None on invalid input. fn base64url_decode(input: &str) -> Option { let input = input.trim_end_matches('='); let mut bytes = Vec::with_capacity(input.len() * 3 / 4); let mut buf: u32 = 0; let mut bits: u32 = 0; for c in input.bytes() { let val = match c { b'A'..=b'Z' => c - b'A', b'a'..=b'z' => c - b'a' + 26, b'0'..=b'9' => c - b'0' + 52, b'-' => 62, b'_' => 63, b'+' => 62, // accept standard base64 too b'/' => 63, b'\n' | b'\r' | b' ' => continue, _ => return None, }; buf = (buf << 6) | val as u32; bits += 6; if bits >= 8 { bits -= 8; bytes.push((buf >> bits) as u8); buf &= (1 << bits) - 1; } } String::from_utf8(bytes).ok() } /// Minimal percent-encoding for URL path segments and query values. fn url_encode(s: &str) -> String { let mut encoded = String::with_capacity(s.len()); for b in s.bytes() { match b { b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { encoded.push(b as char); } _ => { encoded.push('%'); encoded.push(char::from(HEX[(b >> 4) as usize])); encoded.push(char::from(HEX[(b & 0x0F) as usize])); } } } encoded } const HEX: [u8; 16] = *b"0123456789ABCDEF";