mirror of
https://github.com/outbackdingo/optimclaw.git
synced 2026-08-26 15:40:18 +00:00
feat: Add Google Suite & Telegram WASM tools (#9)
* Add Google Calendar and Gmail WASM tools, and /add-tool skill Scaffold two new WASM tools that share a single Google OAuth token: - google-calendar: list/get/create/update/delete calendar events - gmail: list/search/get/send/draft/reply/trash emails Both tools use the sandboxed WIT interface with strict HTTP allowlists, credential injection, and rate limiting. OAuth config requests only the minimum scopes needed (calendar.events, gmail.modify, gmail.compose). Also adds the /add-tool skill for scaffolding future WASM or built-in tools with all boilerplate wired up. Co-Authored-By: Claude Opus 4.6 <[email protected]> * Document WASM vs MCP server decision guide in CLAUDE.md Co-Authored-By: Claude Opus 4.6 <[email protected]> * Add Google Drive WASM tool with full file and sharing management Supports 12 actions: list/get/download/upload/update files, create folders, delete/trash, share/list/remove permissions, and list shared drives. Works with both personal and organizational drives via the corpora parameter. Uses shared google_oauth_token for auth. Co-Authored-By: Claude Opus 4.6 <[email protected]> * Add Google Sheets, Docs, and Slides WASM tools Three new Google Workspace tools sharing google_oauth_token: - Sheets: create spreadsheets, read/write/append values, manage sheets, format cells - Docs: create/read/edit documents, text formatting, paragraphs, tables, lists - Slides: create/edit presentations, shapes, images, text formatting, thumbnails, templates Also adds tools-src/TOOLS.md tracking implementation status. Co-Authored-By: Claude Opus 4.6 <[email protected]> * Add Telegram WASM tool with direct MTProto over HTTPS Replace TDLight Docker dependency with pure-Rust grammers crates for direct encrypted MTProto communication to Telegram's web transport endpoints. No middleware, no Docker needed. Co-Authored-By: Claude Opus 4.6 <[email protected]> * Gitignore Cargo.lock files in WASM tools Library crates should not commit lock files. Consolidate per-tool .gitignore into a single one at wasm-tools/ level. Co-Authored-By: Claude Opus 4.6 <[email protected]> * Flatten tools-src/wasm-tools/ into tools-src/ All tools are WASM, the extra nesting added no value. Moves all tool crates up one level, updates WIT paths and documentation references. Co-Authored-By: Claude Opus 4.6 <[email protected]> * Fix Slack tool: add OAuth auth, URL encoding, pin wit-bindgen - Add OAuth 2.0 auth section to Slack capabilities with proper scopes and manual fallback instructions - URL-encode query parameters in GET requests to prevent injection - Remove dead SlackApiError struct - Pin wit-bindgen to =0.36 across all WASM tools for Rust 1.86 compat - Update add-tool template with pinned version Co-Authored-By: Claude Opus 4.6 <[email protected]> --------- Co-authored-by: Claude Opus 4.6 <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
e6725eb6d9
commit
a35db4d32d
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "gmail-tool"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Gmail integration tool for IronClaw (WASM component)"
|
||||
license = "MIT OR Apache-2.0"
|
||||
publish = false
|
||||
|
||||
[lib]
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
wit-bindgen = "=0.36"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
strip = true
|
||||
codegen-units = 1
|
||||
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"http": {
|
||||
"allowlist": [
|
||||
{
|
||||
"host": "gmail.googleapis.com",
|
||||
"path_prefix": "/gmail/v1/",
|
||||
"methods": ["GET", "POST", "DELETE"]
|
||||
}
|
||||
],
|
||||
"credentials": {
|
||||
"google_oauth_token": {
|
||||
"secret_name": "google_oauth_token",
|
||||
"location": { "type": "bearer" },
|
||||
"host_patterns": ["gmail.googleapis.com"]
|
||||
}
|
||||
},
|
||||
"rate_limit": {
|
||||
"requests_per_minute": 60,
|
||||
"requests_per_hour": 500
|
||||
},
|
||||
"timeout_secs": 30
|
||||
},
|
||||
"secrets": {
|
||||
"allowed_names": ["google_oauth_token"]
|
||||
},
|
||||
"auth": {
|
||||
"secret_name": "google_oauth_token",
|
||||
"display_name": "Google",
|
||||
"oauth": {
|
||||
"authorization_url": "https://accounts.google.com/o/oauth2/v2/auth",
|
||||
"token_url": "https://oauth2.googleapis.com/token",
|
||||
"client_id_env": "GOOGLE_OAUTH_CLIENT_ID",
|
||||
"client_secret_env": "GOOGLE_OAUTH_CLIENT_SECRET",
|
||||
"scopes": [
|
||||
"https://www.googleapis.com/auth/gmail.modify",
|
||||
"https://www.googleapis.com/auth/gmail.compose"
|
||||
],
|
||||
"use_pkce": false,
|
||||
"extra_params": {
|
||||
"access_type": "offline",
|
||||
"prompt": "consent"
|
||||
}
|
||||
},
|
||||
"env_var": "GOOGLE_OAUTH_TOKEN"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
//! 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())?;
|
||||
|
||||
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.contains(&"UNREAD".to_string()),
|
||||
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.contains(&"UNREAD".to_string()),
|
||||
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() + 2) / 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";
|
||||
@@ -0,0 +1,253 @@
|
||||
//! Gmail WASM Tool for IronClaw.
|
||||
//!
|
||||
//! Provides Gmail integration for reading, searching, sending, drafting,
|
||||
//! and replying to emails.
|
||||
//!
|
||||
//! # Capabilities Required
|
||||
//!
|
||||
//! - HTTP: `gmail.googleapis.com/gmail/v1/*` (GET, POST, DELETE)
|
||||
//! - Secrets: `google_oauth_token` (shared OAuth 2.0 token, injected automatically)
|
||||
//!
|
||||
//! # Supported Actions
|
||||
//!
|
||||
//! - `list_messages`: List/search messages with Gmail query syntax
|
||||
//! - `get_message`: Get a specific message with full content
|
||||
//! - `send_message`: Send a new email
|
||||
//! - `create_draft`: Create a draft email
|
||||
//! - `reply_to_message`: Reply to an existing message (or reply-all)
|
||||
//! - `trash_message`: Move a message to trash
|
||||
//!
|
||||
//! # Example Usage
|
||||
//!
|
||||
//! ```json
|
||||
//! {"action": "list_messages", "query": "is:unread from:[email protected]", "max_results": 5}
|
||||
//! ```
|
||||
|
||||
mod api;
|
||||
mod types;
|
||||
|
||||
use types::GmailAction;
|
||||
|
||||
wit_bindgen::generate!({
|
||||
world: "sandboxed-tool",
|
||||
path: "../../wit/tool.wit",
|
||||
});
|
||||
|
||||
struct GmailTool;
|
||||
|
||||
impl exports::near::agent::tool::Guest for GmailTool {
|
||||
fn execute(req: exports::near::agent::tool::Request) -> exports::near::agent::tool::Response {
|
||||
match execute_inner(&req.params) {
|
||||
Ok(result) => exports::near::agent::tool::Response {
|
||||
output: Some(result),
|
||||
error: None,
|
||||
},
|
||||
Err(e) => exports::near::agent::tool::Response {
|
||||
output: None,
|
||||
error: Some(e),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn schema() -> String {
|
||||
r#"{
|
||||
"type": "object",
|
||||
"required": ["action"],
|
||||
"oneOf": [
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "list_messages" },
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Gmail search query (same syntax as Gmail search box). Examples: 'is:unread', 'from:[email protected]', 'subject:meeting after:2025/01/01'"
|
||||
},
|
||||
"max_results": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of messages to return (default: 20)",
|
||||
"default": 20
|
||||
},
|
||||
"label_ids": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" },
|
||||
"description": "Label IDs to filter by (e.g., 'INBOX', 'SENT', 'DRAFT')"
|
||||
}
|
||||
},
|
||||
"required": ["action"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "get_message" },
|
||||
"message_id": {
|
||||
"type": "string",
|
||||
"description": "The message ID to retrieve"
|
||||
}
|
||||
},
|
||||
"required": ["action", "message_id"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "send_message" },
|
||||
"to": {
|
||||
"type": "string",
|
||||
"description": "Recipient email address(es), comma-separated"
|
||||
},
|
||||
"subject": {
|
||||
"type": "string",
|
||||
"description": "Email subject"
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "Email body (plain text)"
|
||||
},
|
||||
"cc": {
|
||||
"type": "string",
|
||||
"description": "CC recipients, comma-separated"
|
||||
},
|
||||
"bcc": {
|
||||
"type": "string",
|
||||
"description": "BCC recipients, comma-separated"
|
||||
}
|
||||
},
|
||||
"required": ["action", "to", "subject", "body"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "create_draft" },
|
||||
"to": {
|
||||
"type": "string",
|
||||
"description": "Recipient email address(es), comma-separated"
|
||||
},
|
||||
"subject": {
|
||||
"type": "string",
|
||||
"description": "Email subject"
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "Email body (plain text)"
|
||||
},
|
||||
"cc": {
|
||||
"type": "string",
|
||||
"description": "CC recipients, comma-separated"
|
||||
},
|
||||
"bcc": {
|
||||
"type": "string",
|
||||
"description": "BCC recipients, comma-separated"
|
||||
}
|
||||
},
|
||||
"required": ["action", "to", "subject", "body"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "reply_to_message" },
|
||||
"message_id": {
|
||||
"type": "string",
|
||||
"description": "The message ID to reply to"
|
||||
},
|
||||
"body": {
|
||||
"type": "string",
|
||||
"description": "Reply body (plain text)"
|
||||
},
|
||||
"reply_all": {
|
||||
"type": "boolean",
|
||||
"description": "If true, reply to all recipients (default: false)",
|
||||
"default": false
|
||||
}
|
||||
},
|
||||
"required": ["action", "message_id", "body"]
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
"action": { "const": "trash_message" },
|
||||
"message_id": {
|
||||
"type": "string",
|
||||
"description": "The message ID to move to trash"
|
||||
}
|
||||
},
|
||||
"required": ["action", "message_id"]
|
||||
}
|
||||
]
|
||||
}"#
|
||||
.to_string()
|
||||
}
|
||||
|
||||
fn description() -> String {
|
||||
"Gmail integration for reading, searching, sending, drafting, and replying to emails. \
|
||||
Supports Gmail search query syntax (is:unread, from:, subject:, after:, etc.). \
|
||||
Requires a Google OAuth token with gmail.modify and gmail.compose scopes."
|
||||
.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn execute_inner(params: &str) -> Result<String, String> {
|
||||
if !crate::near::agent::host::secret_exists("google_oauth_token") {
|
||||
return Err(
|
||||
"Google OAuth token not configured. Run `ironclaw tool auth gmail` to set up \
|
||||
OAuth, or set the GOOGLE_OAUTH_TOKEN environment variable."
|
||||
.to_string(),
|
||||
);
|
||||
}
|
||||
|
||||
let action: GmailAction =
|
||||
serde_json::from_str(params).map_err(|e| format!("Invalid parameters: {}", e))?;
|
||||
|
||||
crate::near::agent::host::log(
|
||||
crate::near::agent::host::LogLevel::Info,
|
||||
&format!("Executing Gmail action: {:?}", action),
|
||||
);
|
||||
|
||||
let result = match action {
|
||||
GmailAction::ListMessages {
|
||||
query,
|
||||
max_results,
|
||||
label_ids,
|
||||
} => {
|
||||
let result = api::list_messages(query.as_deref(), max_results, &label_ids)?;
|
||||
serde_json::to_string(&result).map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
GmailAction::GetMessage { message_id } => {
|
||||
let result = api::get_message(&message_id)?;
|
||||
serde_json::to_string(&result).map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
GmailAction::SendMessage {
|
||||
to,
|
||||
subject,
|
||||
body,
|
||||
cc,
|
||||
bcc,
|
||||
} => {
|
||||
let result = api::send_message(&to, &subject, &body, cc.as_deref(), bcc.as_deref())?;
|
||||
serde_json::to_string(&result).map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
GmailAction::CreateDraft {
|
||||
to,
|
||||
subject,
|
||||
body,
|
||||
cc,
|
||||
bcc,
|
||||
} => {
|
||||
let result = api::create_draft(&to, &subject, &body, cc.as_deref(), bcc.as_deref())?;
|
||||
serde_json::to_string(&result).map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
GmailAction::ReplyToMessage {
|
||||
message_id,
|
||||
body,
|
||||
reply_all,
|
||||
} => {
|
||||
let result = api::reply_to_message(&message_id, &body, reply_all)?;
|
||||
serde_json::to_string(&result).map_err(|e| e.to_string())?
|
||||
}
|
||||
|
||||
GmailAction::TrashMessage { message_id } => {
|
||||
let result = api::trash_message(&message_id)?;
|
||||
serde_json::to_string(&result).map_err(|e| e.to_string())?
|
||||
}
|
||||
};
|
||||
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
export!(GmailTool);
|
||||
@@ -0,0 +1,144 @@
|
||||
//! Types for Gmail API requests and responses.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// Input parameters for the Gmail tool.
|
||||
#[derive(Debug, Deserialize)]
|
||||
#[serde(tag = "action", rename_all = "snake_case")]
|
||||
pub enum GmailAction {
|
||||
/// List messages in the mailbox.
|
||||
ListMessages {
|
||||
/// Gmail search query (same syntax as the Gmail search box).
|
||||
/// Examples: "from:[email protected]", "subject:meeting", "is:unread",
|
||||
/// "after:2025/01/01 before:2025/02/01".
|
||||
#[serde(default)]
|
||||
query: Option<String>,
|
||||
/// Maximum number of messages to return (default: 20).
|
||||
#[serde(default = "default_max_results")]
|
||||
max_results: u32,
|
||||
/// Label IDs to filter by (e.g., "INBOX", "SENT", "DRAFT").
|
||||
#[serde(default)]
|
||||
label_ids: Vec<String>,
|
||||
},
|
||||
|
||||
/// Get a specific message with full content.
|
||||
GetMessage {
|
||||
/// The message ID.
|
||||
message_id: String,
|
||||
},
|
||||
|
||||
/// Send an email.
|
||||
SendMessage {
|
||||
/// Recipient email address(es), comma-separated.
|
||||
to: String,
|
||||
/// Email subject.
|
||||
subject: String,
|
||||
/// Email body (plain text).
|
||||
body: String,
|
||||
/// CC recipients, comma-separated.
|
||||
#[serde(default)]
|
||||
cc: Option<String>,
|
||||
/// BCC recipients, comma-separated.
|
||||
#[serde(default)]
|
||||
bcc: Option<String>,
|
||||
},
|
||||
|
||||
/// Create a draft email.
|
||||
CreateDraft {
|
||||
/// Recipient email address(es), comma-separated.
|
||||
to: String,
|
||||
/// Email subject.
|
||||
subject: String,
|
||||
/// Email body (plain text).
|
||||
body: String,
|
||||
/// CC recipients, comma-separated.
|
||||
#[serde(default)]
|
||||
cc: Option<String>,
|
||||
/// BCC recipients, comma-separated.
|
||||
#[serde(default)]
|
||||
bcc: Option<String>,
|
||||
},
|
||||
|
||||
/// Reply to an existing message.
|
||||
ReplyToMessage {
|
||||
/// The message ID to reply to.
|
||||
message_id: String,
|
||||
/// Reply body (plain text).
|
||||
body: String,
|
||||
/// If true, reply to all recipients. Default: false.
|
||||
#[serde(default)]
|
||||
reply_all: bool,
|
||||
},
|
||||
|
||||
/// Move a message to trash.
|
||||
TrashMessage {
|
||||
/// The message ID to trash.
|
||||
message_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
fn default_max_results() -> u32 {
|
||||
20
|
||||
}
|
||||
|
||||
/// A Gmail message summary (from list endpoint).
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct MessageSummary {
|
||||
pub id: String,
|
||||
pub thread_id: String,
|
||||
pub subject: String,
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
pub date: String,
|
||||
pub snippet: String,
|
||||
pub label_ids: Vec<String>,
|
||||
pub is_unread: bool,
|
||||
}
|
||||
|
||||
/// A full Gmail message (from get endpoint).
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct Message {
|
||||
pub id: String,
|
||||
pub thread_id: String,
|
||||
pub subject: String,
|
||||
pub from: String,
|
||||
pub to: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub cc: Option<String>,
|
||||
pub date: String,
|
||||
pub body: String,
|
||||
pub snippet: String,
|
||||
pub label_ids: Vec<String>,
|
||||
pub is_unread: bool,
|
||||
}
|
||||
|
||||
/// Result from list_messages.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct ListMessagesResult {
|
||||
pub messages: Vec<MessageSummary>,
|
||||
pub result_size_estimate: u32,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub next_page_token: Option<String>,
|
||||
}
|
||||
|
||||
/// Result from send_message or reply_to_message.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct SendResult {
|
||||
pub id: String,
|
||||
pub thread_id: String,
|
||||
pub label_ids: Vec<String>,
|
||||
}
|
||||
|
||||
/// Result from create_draft.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct DraftResult {
|
||||
pub id: String,
|
||||
pub message_id: String,
|
||||
}
|
||||
|
||||
/// Result from trash_message.
|
||||
#[derive(Debug, Serialize)]
|
||||
pub struct TrashResult {
|
||||
pub id: String,
|
||||
pub trashed: bool,
|
||||
}
|
||||
Reference in New Issue
Block a user