mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-27 16:10:09 +00:00
- Telegram: remove .unwrap() panic on workspace_read (owner_id check) - WhatsApp: use configured api_version instead of hardcoded v18.0 - WhatsApp: log config parse errors before falling back to defaults - Slack: log serialization errors in emit_message and json_response - Google Docs: safe array access for batch update replies - Google Sheets: safe array access for add_sheet replies - Google Calendar: fix doc comment secret name mismatch - Gmail: avoid unnecessary String allocation in UNREAD check Co-Authored-By: Claude Opus 4.6 <[email protected]>
468 lines
15 KiB
Rust
468 lines
15 KiB
Rust
//! 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<String, String> {
|
|
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<String> = 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<ListMessagesResult, String> {
|
|
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<String> = 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::<serde_json::Value>(&msg_response) {
|
|
let payload = &msg["payload"];
|
|
let label_ids: Vec<String> = 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<Message, String> {
|
|
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<SendResult, String> {
|
|
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<DraftResult, String> {
|
|
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<SendResult, String> {
|
|
// 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<TrashResult, String> {
|
|
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<String> {
|
|
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";
|