Files
google-mcp/gmail/gmail_tools.py

2250 lines
82 KiB
Python
Raw Normal View History

2025-05-12 17:22:58 -04:00
"""
Google Gmail MCP Tools
This module provides MCP tools for interacting with the Gmail API.
"""
2025-05-22 14:21:52 -04:00
2025-05-12 17:22:58 -04:00
import logging
import asyncio
import base64
import ssl
import mimetypes
2025-12-19 15:58:45 -05:00
from html.parser import HTMLParser
2026-02-13 10:07:10 -05:00
from typing import Annotated, Optional, List, Dict, Literal, Any
2025-05-12 17:22:58 -04:00
from email.mime.text import MIMEText
from email.mime.multipart import MIMEMultipart
from email.mime.base import MIMEBase
from email import encoders
2026-01-30 10:33:49 -05:00
from email.utils import formataddr
2025-08-13 12:04:07 -04:00
from pydantic import Field
2026-03-01 12:06:23 -05:00
from googleapiclient.errors import HttpError
2025-05-12 17:22:58 -04:00
from auth.service_decorator import require_google_service
2026-03-01 12:06:23 -05:00
from core.utils import handle_http_errors, validate_file_path, UserInputError
2025-08-03 10:30:04 -04:00
from core.server import server
from auth.scopes import (
2025-05-12 17:22:58 -04:00
GMAIL_SEND_SCOPE,
GMAIL_COMPOSE_SCOPE,
GMAIL_MODIFY_SCOPE,
GMAIL_LABELS_SCOPE,
2025-05-12 17:22:58 -04:00
)
logger = logging.getLogger(__name__)
2025-07-23 14:01:02 -04:00
GMAIL_BATCH_SIZE = 25
GMAIL_REQUEST_DELAY = 0.1
2025-08-13 12:04:07 -04:00
HTML_BODY_TRUNCATE_LIMIT = 20000
GMAIL_METADATA_HEADERS = ["Subject", "From", "To", "Cc", "Message-ID", "Date"]
LOW_VALUE_TEXT_PLACEHOLDERS = (
"your client does not support html",
"view this email in your browser",
"open this email in your browser",
)
LOW_VALUE_TEXT_FOOTER_MARKERS = (
"mailing list",
"mailman/listinfo",
"unsubscribe",
"list-unsubscribe",
"manage preferences",
)
LOW_VALUE_TEXT_HTML_DIFF_MIN = 80
2025-07-23 14:01:02 -04:00
2025-05-22 14:21:52 -04:00
2025-12-19 15:58:45 -05:00
class _HTMLTextExtractor(HTMLParser):
"""Extract readable text from HTML using stdlib."""
def __init__(self):
super().__init__()
self._text = []
self._skip = False
def handle_starttag(self, tag, attrs):
self._skip = tag in ("script", "style")
def handle_endtag(self, tag):
if tag in ("script", "style"):
self._skip = False
def handle_data(self, data):
if not self._skip:
self._text.append(data)
def get_text(self) -> str:
return " ".join("".join(self._text).split())
def _html_to_text(html: str) -> str:
"""Convert HTML to readable plain text."""
try:
parser = _HTMLTextExtractor()
parser.feed(html)
return parser.get_text()
except Exception:
return html
def _extract_message_body(payload):
"""
Helper function to extract plain text body from a Gmail message payload.
(Maintained for backward compatibility)
Args:
payload (dict): The message payload from Gmail API
Returns:
str: The plain text body content, or empty string if not found
"""
bodies = _extract_message_bodies(payload)
return bodies.get("text", "")
def _extract_message_bodies(payload):
"""
Helper function to extract both plain text and HTML bodies from a Gmail message payload.
Args:
payload (dict): The message payload from Gmail API
Returns:
dict: Dictionary with 'text' and 'html' keys containing body content
"""
text_body = ""
html_body = ""
parts = [payload] if "parts" not in payload else payload.get("parts", [])
part_queue = list(parts) # Use a queue for BFS traversal of parts
while part_queue:
part = part_queue.pop(0)
mime_type = part.get("mimeType", "")
body_data = part.get("body", {}).get("data")
2025-08-13 12:05:06 -04:00
if body_data:
try:
2025-12-13 13:49:28 -08:00
decoded_data = base64.urlsafe_b64decode(body_data).decode(
"utf-8", errors="ignore"
)
if mime_type == "text/plain" and not text_body:
text_body = decoded_data
elif mime_type == "text/html" and not html_body:
html_body = decoded_data
except Exception as e:
logger.warning(f"Failed to decode body part: {e}")
2025-08-13 12:05:06 -04:00
# Add sub-parts to queue for multipart messages
if mime_type.startswith("multipart/") and "parts" in part:
part_queue.extend(part.get("parts", []))
# Check the main payload if it has body data directly
if payload.get("body", {}).get("data"):
try:
2025-12-13 13:49:28 -08:00
decoded_data = base64.urlsafe_b64decode(payload["body"]["data"]).decode(
"utf-8", errors="ignore"
)
mime_type = payload.get("mimeType", "")
if mime_type == "text/plain" and not text_body:
text_body = decoded_data
elif mime_type == "text/html" and not html_body:
html_body = decoded_data
except Exception as e:
logger.warning(f"Failed to decode main payload body: {e}")
2025-12-13 13:49:28 -08:00
return {"text": text_body, "html": html_body}
2025-08-13 12:04:07 -04:00
def _format_body_content(text_body: str, html_body: str) -> str:
"""
Helper function to format message body content with HTML fallback and truncation.
2025-12-19 15:58:45 -05:00
Detects useless text/plain fallbacks (e.g., "Your client does not support HTML").
2025-08-13 12:05:06 -04:00
2025-08-13 12:04:07 -04:00
Args:
text_body: Plain text body content
html_body: HTML body content
2025-08-13 12:05:06 -04:00
2025-08-13 12:04:07 -04:00
Returns:
Formatted body content string
"""
2025-12-19 15:58:45 -05:00
text_stripped = text_body.strip()
html_stripped = html_body.strip()
html_text = _html_to_text(html_stripped).strip() if html_stripped else ""
plain_lower = " ".join(text_stripped.split()).lower()
html_lower = " ".join(html_text.split()).lower()
plain_is_low_value = plain_lower and (
any(marker in plain_lower for marker in LOW_VALUE_TEXT_PLACEHOLDERS)
or (
any(marker in plain_lower for marker in LOW_VALUE_TEXT_FOOTER_MARKERS)
and len(html_lower) >= len(plain_lower) + LOW_VALUE_TEXT_HTML_DIFF_MIN
)
or (
len(html_lower) >= len(plain_lower) + LOW_VALUE_TEXT_HTML_DIFF_MIN
and html_lower.endswith(plain_lower)
)
2025-12-19 15:58:45 -05:00
)
# Prefer plain text, but fall back to HTML when plain text is empty or clearly low-value.
2026-03-03 15:12:33 +00:00
use_html = html_text and (
not text_stripped or "<!--" in text_stripped or plain_is_low_value
)
2025-12-19 15:58:45 -05:00
if use_html:
content = html_text
2025-12-19 15:58:45 -05:00
if len(content) > HTML_BODY_TRUNCATE_LIMIT:
content = content[:HTML_BODY_TRUNCATE_LIMIT] + "\n\n[Content truncated...]"
return content
elif text_stripped:
2025-08-13 12:04:07 -04:00
return text_body
else:
return "[No readable content found]"
2026-03-01 12:06:23 -05:00
def _append_signature_to_body(
body: str, body_format: Literal["plain", "html"], signature_html: str
) -> str:
"""Append a Gmail signature to the outgoing body, preserving body format."""
if not signature_html or not signature_html.strip():
return body
if body_format == "html":
separator = "<br><br>" if body.strip() else ""
return f"{body}{separator}{signature_html}"
signature_text = _html_to_text(signature_html).strip()
if not signature_text:
return body
separator = "\n\n" if body.strip() else ""
return f"{body}{separator}{signature_text}"
async def _fetch_original_for_quote(service, thread_id: str, in_reply_to: Optional[str] = None) -> Optional[dict]:
"""Fetch the original message from a thread for quoting in a reply.
When *in_reply_to* is provided the function looks for that specific
Message-ID inside the thread. Otherwise it falls back to the last
message in the thread.
Returns a dict with keys: sender, date, text_body, html_body -- or
*None* when the message cannot be retrieved.
"""
try:
thread_data = await asyncio.to_thread(
service.users()
.threads()
.get(userId="me", id=thread_id, format="full")
.execute
)
except Exception as e:
logger.warning(f"Failed to fetch thread {thread_id} for quoting: {e}")
return None
messages = thread_data.get("messages", [])
if not messages:
return None
target = None
if in_reply_to:
for msg in messages:
headers = {
h["name"]: h["value"]
for h in msg.get("payload", {}).get("headers", [])
}
if headers.get("Message-ID") == in_reply_to:
target = msg
break
if target is None:
target = messages[-1]
headers = {
h["name"]: h["value"]
for h in target.get("payload", {}).get("headers", [])
}
bodies = _extract_message_bodies(target.get("payload", {}))
return {
"sender": headers.get("From", "unknown"),
"date": headers.get("Date", ""),
"text_body": bodies.get("text", ""),
"html_body": bodies.get("html", ""),
}
def _build_quoted_reply_body(
reply_body: str,
body_format: Literal["plain", "html"],
signature_html: str,
original: dict,
) -> str:
"""Assemble reply body + signature + quoted original message.
Layout:
reply_body
-- signature --
On {date}, {sender} wrote:
> quoted original
"""
import html as _html_mod
if original.get("date"):
attribution = f"On {original['date']}, {original['sender']} wrote:"
else:
attribution = f"{original['sender']} wrote:"
if body_format == "html":
# Signature
sig_block = ""
if signature_html and signature_html.strip():
sig_block = f"<br><br>{signature_html}"
# Quoted original
orig_html = original.get("html_body") or ""
if not orig_html:
orig_text = original.get("text_body", "")
orig_html = f"<pre>{_html_mod.escape(orig_text)}</pre>"
quote_block = (
'<br><br><div class="gmail_quote">'
f"<span>{_html_mod.escape(attribution)}</span><br>"
'<blockquote style="margin:0 0 0 .8ex;border-left:1px solid #ccc;padding-left:1ex">'
f"{orig_html}"
"</blockquote></div>"
)
return f"{reply_body}{sig_block}{quote_block}"
# Plain text path
sig_block = ""
if signature_html and signature_html.strip():
sig_text = _html_to_text(signature_html).strip()
if sig_text:
sig_block = f"\n\n{sig_text}"
orig_text = original.get("text_body") or ""
if not orig_text and original.get("html_body"):
orig_text = _html_to_text(original["html_body"])
quoted_lines = "\n".join(f"> {line}" for line in orig_text.splitlines())
return f"{reply_body}{sig_block}\n\n{attribution}\n{quoted_lines}"
2026-03-01 12:06:23 -05:00
async def _get_send_as_signature_html(service, from_email: Optional[str] = None) -> str:
"""
Fetch signature HTML from Gmail send-as settings.
Returns empty string when the account has no signature configured or the
OAuth token cannot access settings endpoints.
"""
try:
response = await asyncio.to_thread(
service.users().settings().sendAs().list(userId="me").execute
)
except HttpError as e:
status = getattr(getattr(e, "resp", None), "status", None)
if status in {401, 403}:
logger.info(
"Skipping Gmail signature fetch: missing auth/scope for settings endpoint."
)
return ""
logger.warning(f"Failed to fetch Gmail send-as signatures: {e}")
return ""
except Exception as e:
logger.warning(f"Failed to fetch Gmail send-as signatures: {e}")
return ""
send_as_entries = response.get("sendAs", [])
if not send_as_entries:
return ""
if from_email:
from_email_normalized = from_email.strip().lower()
for entry in send_as_entries:
if entry.get("sendAsEmail", "").strip().lower() == from_email_normalized:
return entry.get("signature", "") or ""
for entry in send_as_entries:
if entry.get("isPrimary"):
return entry.get("signature", "") or ""
return send_as_entries[0].get("signature", "") or ""
def _format_attachment_result(attached_count: int, requested_count: int) -> str:
"""Format attachment result message for user-facing responses."""
if requested_count <= 0:
return ""
if attached_count == requested_count:
return f" with {attached_count} attachment(s)"
return f" with {attached_count}/{requested_count} attachment(s) attached"
2025-10-23 14:49:47 -04:00
def _extract_attachments(payload: dict) -> List[Dict[str, Any]]:
"""
Extract attachment metadata from a Gmail message payload.
Args:
payload: The message payload from Gmail API
Returns:
List of attachment dictionaries with filename, mimeType, size, and attachmentId
"""
attachments = []
def search_parts(part):
"""Recursively search for attachments in message parts"""
# Check if this part is an attachment
if part.get("filename") and part.get("body", {}).get("attachmentId"):
2025-12-13 13:49:28 -08:00
attachments.append(
{
"filename": part["filename"],
"mimeType": part.get("mimeType", "application/octet-stream"),
"size": part.get("body", {}).get("size", 0),
"attachmentId": part["body"]["attachmentId"],
}
)
# Recursively search sub-parts
if "parts" in part:
for subpart in part["parts"]:
search_parts(subpart)
# Start searching from the root payload
search_parts(payload)
return attachments
def _extract_headers(payload: dict, header_names: List[str]) -> Dict[str, str]:
"""
Extract specified headers from a Gmail message payload.
Args:
payload: The message payload from Gmail API
header_names: List of header names to extract
Returns:
Dict mapping header names to their values
"""
headers = {}
target_headers = {name.lower(): name for name in header_names}
for header in payload.get("headers", []):
header_name_lower = header["name"].lower()
if header_name_lower in target_headers:
# Store using the original requested casing
headers[target_headers[header_name_lower]] = header["value"]
return headers
def _prepare_gmail_message(
subject: str,
body: str,
to: Optional[str] = None,
cc: Optional[str] = None,
bcc: Optional[str] = None,
thread_id: Optional[str] = None,
in_reply_to: Optional[str] = None,
references: Optional[str] = None,
2025-10-05 16:50:58 -04:00
body_format: Literal["plain", "html"] = "plain",
from_email: Optional[str] = None,
from_name: Optional[str] = None,
attachments: Optional[List[Dict[str, str]]] = None,
2026-03-01 12:06:23 -05:00
) -> tuple[str, Optional[str], int]:
"""
Prepare a Gmail message with threading and attachment support.
2025-08-13 12:05:06 -04:00
Args:
subject: Email subject
2025-10-05 16:50:58 -04:00
body: Email body content
to: Optional recipient email address
cc: Optional CC email address
bcc: Optional BCC email address
thread_id: Optional Gmail thread ID to reply within
in_reply_to: Optional Message-ID of the message being replied to
references: Optional chain of Message-IDs for proper threading
2025-10-05 16:50:58 -04:00
body_format: Content type for the email body ('plain' or 'html')
from_email: Optional sender email address
from_name: Optional sender display name (e.g., "Peter Hartree")
attachments: Optional list of attachments. Each can have 'path' (file path) OR 'content' (base64) + 'filename'
2025-08-13 12:05:06 -04:00
Returns:
2026-03-01 12:06:23 -05:00
Tuple of (raw_message, thread_id, attached_count) where raw_message is base64 encoded
"""
# Handle reply subject formatting
reply_subject = subject
2025-12-13 13:49:28 -08:00
if in_reply_to and not subject.lower().startswith("re:"):
reply_subject = f"Re: {subject}"
# Prepare the email
2025-10-05 16:50:58 -04:00
normalized_format = body_format.lower()
if normalized_format not in {"plain", "html"}:
raise ValueError("body_format must be either 'plain' or 'html'.")
# Use multipart if attachments are provided
2026-03-01 12:06:23 -05:00
attached_count = 0
if attachments:
message = MIMEMultipart()
message.attach(MIMEText(body, normalized_format))
# Process attachments
for attachment in attachments:
file_path = attachment.get("path")
filename = attachment.get("filename")
content_base64 = attachment.get("content")
mime_type = attachment.get("mime_type")
try:
# If path is provided, read and encode the file
if file_path:
path_obj = validate_file_path(file_path)
if not path_obj.exists():
logger.error(f"File not found: {file_path}")
continue
# Read file content
with open(path_obj, "rb") as f:
file_data = f.read()
# Use provided filename or extract from path
if not filename:
filename = path_obj.name
# Auto-detect MIME type if not provided
if not mime_type:
mime_type, _ = mimetypes.guess_type(str(path_obj))
if not mime_type:
mime_type = "application/octet-stream"
# If content is provided (base64), decode it
elif content_base64:
if not filename:
2026-01-29 19:23:23 -05:00
logger.warning("Skipping attachment: missing filename")
continue
2026-01-29 19:43:09 -05:00
file_data = base64.b64decode(content_base64)
if not mime_type:
mime_type = "application/octet-stream"
else:
2026-01-29 19:43:21 -05:00
logger.warning("Skipping attachment: missing both path and content")
continue
# Create MIME attachment
2026-01-29 19:43:09 -05:00
main_type, sub_type = mime_type.split("/", 1)
part = MIMEBase(main_type, sub_type)
part.set_payload(file_data)
encoders.encode_base64(part)
# Use add_header with keyword argument so Python's email
# library applies RFC 2231 encoding for non-ASCII filenames
# (e.g. filename*=utf-8''Pr%C3%BCfbericht.pdf). Manual
# string formatting would drop non-ASCII characters and cause
# Gmail to display "noname".
2026-01-29 19:43:09 -05:00
safe_filename = (
(filename or "attachment")
2026-01-29 19:43:09 -05:00
.replace("\r", "")
.replace("\n", "")
.replace("\x00", "")
) or "attachment"
part.add_header(
"Content-Disposition", "attachment", filename=safe_filename
)
message.attach(part)
2026-03-01 12:06:23 -05:00
attached_count += 1
logger.info(f"Attached file: {filename} ({len(file_data)} bytes)")
except Exception as e:
logger.error(f"Failed to attach {filename or file_path}: {e}")
continue
else:
message = MIMEText(body, normalized_format)
message["Subject"] = reply_subject
# Add sender if provided
if from_email:
if from_name:
2026-01-30 10:33:49 -05:00
# Sanitize from_name to prevent header injection
2026-01-30 10:47:54 -05:00
safe_name = (
from_name.replace("\r", "").replace("\n", "").replace("\x00", "")
)
2026-01-30 10:33:49 -05:00
message["From"] = formataddr((safe_name, from_email))
else:
message["From"] = from_email
# Add recipients if provided
if to:
message["To"] = to
if cc:
message["Cc"] = cc
if bcc:
message["Bcc"] = bcc
# Add reply headers for threading
if in_reply_to:
message["In-Reply-To"] = in_reply_to
2025-08-13 12:05:06 -04:00
if references:
message["References"] = references
# Encode message
raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode()
2025-08-13 12:05:06 -04:00
2026-03-01 12:06:23 -05:00
return raw_message, thread_id, attached_count
def _generate_gmail_web_url(item_id: str, account_index: int = 0) -> str:
"""
Generate Gmail web interface URL for a message or thread ID.
Uses #all to access messages from any Gmail folder/label (not just inbox).
Args:
item_id: Gmail message ID or thread ID
account_index: Google account index (default 0 for primary account)
Returns:
Gmail web interface URL that opens the message/thread in Gmail web interface
"""
return f"https://mail.google.com/mail/u/{account_index}/#all/{item_id}"
2025-12-13 13:49:28 -08:00
def _format_gmail_results_plain(
messages: list, query: str, next_page_token: Optional[str] = None
) -> str:
2025-05-26 17:51:34 -04:00
"""Format Gmail search results in clean, LLM-friendly plain text."""
if not messages:
return f"No messages found for query: '{query}'"
lines = [
f"Found {len(messages)} messages matching '{query}':",
"",
"📧 MESSAGES:",
]
for i, msg in enumerate(messages, 1):
# Handle potential null/undefined message objects
if not msg or not isinstance(msg, dict):
2025-12-13 13:49:28 -08:00
lines.extend(
[
f" {i}. Message: Invalid message data",
" Error: Message object is null or malformed",
"",
]
)
continue
# Handle potential null/undefined values from Gmail API
message_id = msg.get("id")
thread_id = msg.get("threadId")
# Convert None, empty string, or missing values to "unknown"
if not message_id:
message_id = "unknown"
if not thread_id:
thread_id = "unknown"
if message_id != "unknown":
message_url = _generate_gmail_web_url(message_id)
else:
message_url = "N/A"
if thread_id != "unknown":
thread_url = _generate_gmail_web_url(thread_id)
else:
thread_url = "N/A"
2025-05-26 17:51:34 -04:00
2025-07-17 14:46:25 -04:00
lines.extend(
[
f" {i}. Message ID: {message_id}",
2025-07-17 14:46:25 -04:00
f" Web Link: {message_url}",
f" Thread ID: {thread_id}",
2025-07-17 14:46:25 -04:00
f" Thread Link: {thread_url}",
"",
]
)
lines.extend(
[
"💡 USAGE:",
" • Pass the Message IDs **as a list** to get_gmail_messages_content_batch()",
" e.g. get_gmail_messages_content_batch(message_ids=[...])",
" • Pass the Thread IDs to get_gmail_thread_content() (single) or get_gmail_threads_content_batch() (batch)",
]
)
2025-05-26 17:51:34 -04:00
# Add pagination info if there's a next page
if next_page_token:
lines.append("")
2025-12-13 13:49:28 -08:00
lines.append(
f"📄 PAGINATION: To get the next page, call search_gmail_messages again with page_token='{next_page_token}'"
)
2025-05-26 17:51:34 -04:00
return "\n".join(lines)
2025-05-12 17:22:58 -04:00
@server.tool()
@handle_http_errors("search_gmail_messages", is_read_only=True, service_type="gmail")
@require_google_service("gmail", "gmail_read")
async def search_gmail_messages(
2025-12-13 13:49:28 -08:00
service,
query: str,
user_google_email: str,
page_size: int = 10,
page_token: Optional[str] = None,
) -> str:
2025-05-12 17:22:58 -04:00
"""
Searches messages in a user's Gmail account based on a query.
Returns both Message IDs and Thread IDs for each found message, along with Gmail web interface links for manual verification.
Supports pagination via page_token parameter.
2025-05-12 17:22:58 -04:00
Args:
query (str): The search query. Supports standard Gmail search operators.
2025-05-24 10:43:55 -04:00
user_google_email (str): The user's Google email address. Required.
2025-05-12 17:22:58 -04:00
page_size (int): The maximum number of messages to return. Defaults to 10.
page_token (Optional[str]): Token for retrieving the next page of results. Use the next_page_token from a previous response.
2025-05-12 17:22:58 -04:00
Returns:
str: LLM-friendly structured results with Message IDs, Thread IDs, and clickable Gmail web interface URLs for each found message.
Includes pagination token if more results are available.
2025-05-12 17:22:58 -04:00
"""
2025-07-17 14:46:25 -04:00
logger.info(
f"[search_gmail_messages] Email: '{user_google_email}', Query: '{query}', Page size: {page_size}"
2025-07-17 14:46:25 -04:00
)
2025-05-12 17:22:58 -04:00
# Build the API request parameters
2025-12-13 13:49:28 -08:00
request_params = {"userId": "me", "q": query, "maxResults": page_size}
# Add page token if provided
if page_token:
request_params["pageToken"] = page_token
logger.info("[search_gmail_messages] Using page_token for pagination")
2025-06-18 17:31:57 -04:00
response = await asyncio.to_thread(
2025-12-13 13:49:28 -08:00
service.users().messages().list(**request_params).execute
2025-06-18 17:31:57 -04:00
)
# Handle potential null response (but empty dict {} is valid)
if response is None:
2025-07-23 14:36:32 -04:00
logger.warning("[search_gmail_messages] Null response from Gmail API")
return f"No response received from Gmail API for query: '{query}'"
2025-06-18 17:31:57 -04:00
messages = response.get("messages", [])
# Additional safety check for null messages array
if messages is None:
messages = []
# Extract next page token for pagination
next_page_token = response.get("nextPageToken")
formatted_output = _format_gmail_results_plain(messages, query, next_page_token)
2025-05-12 17:22:58 -04:00
2025-06-18 17:31:57 -04:00
logger.info(f"[search_gmail_messages] Found {len(messages)} messages")
if next_page_token:
2025-12-13 13:49:28 -08:00
logger.info(
"[search_gmail_messages] More results available (next_page_token present)"
)
2025-06-18 17:31:57 -04:00
return formatted_output
2025-05-12 17:22:58 -04:00
@server.tool()
2025-12-13 13:49:28 -08:00
@handle_http_errors(
"get_gmail_message_content", is_read_only=True, service_type="gmail"
)
@require_google_service("gmail", "gmail_read")
async def get_gmail_message_content(
service, message_id: str, user_google_email: str
) -> str:
2025-05-12 17:22:58 -04:00
"""
Retrieves the full content (subject, sender, recipients, plain text body) of a specific Gmail message.
2025-05-12 17:22:58 -04:00
Args:
message_id (str): The unique ID of the Gmail message to retrieve.
2025-05-24 10:43:55 -04:00
user_google_email (str): The user's Google email address. Required.
2025-05-12 17:22:58 -04:00
Returns:
str: The message details including subject, sender, date, Message-ID, recipients (To, Cc), and body content.
2025-05-12 17:22:58 -04:00
"""
2025-05-22 14:21:52 -04:00
logger.info(
f"[get_gmail_message_content] Invoked. Message ID: '{message_id}', Email: '{user_google_email}'"
2025-05-22 14:21:52 -04:00
)
2025-05-13 12:36:53 -04:00
2025-06-18 17:31:57 -04:00
logger.info(f"[get_gmail_message_content] Using service for: {user_google_email}")
# Fetch message metadata first to get headers
message_metadata = await asyncio.to_thread(
service.users()
.messages()
.get(
userId="me",
id=message_id,
format="metadata",
metadataHeaders=GMAIL_METADATA_HEADERS,
2025-05-12 17:22:58 -04:00
)
2025-06-18 17:31:57 -04:00
.execute
)
2025-05-12 17:22:58 -04:00
headers = _extract_headers(
message_metadata.get("payload", {}), GMAIL_METADATA_HEADERS
)
2025-06-18 17:31:57 -04:00
subject = headers.get("Subject", "(no subject)")
sender = headers.get("From", "(unknown sender)")
to = headers.get("To", "")
cc = headers.get("Cc", "")
rfc822_msg_id = headers.get("Message-ID", "")
2025-06-18 17:31:57 -04:00
# Now fetch the full message to get the body parts
message_full = await asyncio.to_thread(
service.users()
.messages()
.get(
userId="me",
id=message_id,
format="full", # Request full payload for body
2025-05-12 17:22:58 -04:00
)
2025-06-18 17:31:57 -04:00
.execute
)
2025-05-12 17:22:58 -04:00
# Extract both text and HTML bodies using enhanced helper function
2025-06-18 17:31:57 -04:00
payload = message_full.get("payload", {})
bodies = _extract_message_bodies(payload)
text_body = bodies.get("text", "")
html_body = bodies.get("html", "")
2025-08-13 12:05:06 -04:00
# Format body content with HTML fallback
2025-08-13 12:04:07 -04:00
body_data = _format_body_content(text_body, html_body)
2025-05-22 14:21:52 -04:00
# Extract attachment metadata
attachments = _extract_attachments(payload)
content_lines = [
f"Subject: {subject}",
f"From: {sender}",
f"Date: {headers.get('Date', '(unknown date)')}",
]
if rfc822_msg_id:
content_lines.append(f"Message-ID: {rfc822_msg_id}")
if to:
content_lines.append(f"To: {to}")
if cc:
content_lines.append(f"Cc: {cc}")
content_lines.append(f"\n--- BODY ---\n{body_data or '[No text/plain body found]'}")
# Add attachment information if present
if attachments:
content_lines.append("\n--- ATTACHMENTS ---")
for i, att in enumerate(attachments, 1):
2025-12-13 13:49:28 -08:00
size_kb = att["size"] / 1024
content_lines.append(
f"{i}. {att['filename']} ({att['mimeType']}, {size_kb:.1f} KB)\n"
f" Attachment ID: {att['attachmentId']}\n"
f" Use get_gmail_attachment_content(message_id='{message_id}', attachment_id='{att['attachmentId']}') to download"
)
return "\n".join(content_lines)
@server.tool()
2025-12-13 13:49:28 -08:00
@handle_http_errors(
"get_gmail_messages_content_batch", is_read_only=True, service_type="gmail"
)
@require_google_service("gmail", "gmail_read")
async def get_gmail_messages_content_batch(
service,
message_ids: List[str],
user_google_email: str,
format: Literal["full", "metadata"] = "full",
) -> str:
"""
Retrieves the content of multiple Gmail messages in a single batch request.
Supports up to 25 messages per batch to prevent SSL connection exhaustion.
Args:
2025-07-23 14:01:02 -04:00
message_ids (List[str]): List of Gmail message IDs to retrieve (max 25 per batch).
user_google_email (str): The user's Google email address. Required.
format (Literal["full", "metadata"]): Message format. "full" includes body, "metadata" only headers.
Returns:
str: A formatted list of message contents including subject, sender, date, Message-ID, recipients (To, Cc), and body (if full format).
"""
logger.info(
f"[get_gmail_messages_content_batch] Invoked. Message count: {len(message_ids)}, Email: '{user_google_email}'"
)
if not message_ids:
raise Exception("No message IDs provided")
2025-06-18 17:31:57 -04:00
output_messages = []
# Process in smaller chunks to prevent SSL connection exhaustion
2025-07-23 14:01:02 -04:00
for chunk_start in range(0, len(message_ids), GMAIL_BATCH_SIZE):
chunk_ids = message_ids[chunk_start : chunk_start + GMAIL_BATCH_SIZE]
2025-06-18 17:31:57 -04:00
results: Dict[str, Dict] = {}
2025-06-18 17:31:57 -04:00
def _batch_callback(request_id, response, exception):
"""Callback for batch requests"""
results[request_id] = {"data": response, "error": exception}
2025-06-18 17:31:57 -04:00
# Try to use batch API
try:
batch = service.new_batch_http_request(callback=_batch_callback)
2025-06-18 17:31:57 -04:00
for mid in chunk_ids:
if format == "metadata":
2025-07-17 14:46:25 -04:00
req = (
service.users()
.messages()
.get(
userId="me",
id=mid,
format="metadata",
metadataHeaders=GMAIL_METADATA_HEADERS,
2025-07-17 14:46:25 -04:00
)
2025-06-18 17:31:57 -04:00
)
else:
2025-07-17 14:46:25 -04:00
req = (
service.users()
.messages()
.get(userId="me", id=mid, format="full")
2025-06-18 17:31:57 -04:00
)
batch.add(req, request_id=mid)
# Execute batch request
await asyncio.to_thread(batch.execute)
except Exception as batch_error:
# Fallback to sequential processing instead of parallel to prevent SSL exhaustion
2025-06-18 17:31:57 -04:00
logger.warning(
f"[get_gmail_messages_content_batch] Batch API failed, falling back to sequential processing: {batch_error}"
2025-06-18 17:31:57 -04:00
)
async def fetch_message_with_retry(mid: str, max_retries: int = 3):
"""Fetch a single message with exponential backoff retry for SSL errors"""
for attempt in range(max_retries):
try:
if format == "metadata":
msg = await asyncio.to_thread(
service.users()
.messages()
.get(
userId="me",
id=mid,
format="metadata",
metadataHeaders=GMAIL_METADATA_HEADERS,
)
.execute
2025-07-17 14:46:25 -04:00
)
else:
msg = await asyncio.to_thread(
service.users()
.messages()
.get(userId="me", id=mid, format="full")
.execute
)
return mid, msg, None
except ssl.SSLError as ssl_error:
if attempt < max_retries - 1:
# Exponential backoff: 1s, 2s, 4s
2025-12-13 13:49:28 -08:00
delay = 2**attempt
logger.warning(
f"[get_gmail_messages_content_batch] SSL error for message {mid} on attempt {attempt + 1}: {ssl_error}. Retrying in {delay}s..."
)
await asyncio.sleep(delay)
else:
logger.error(
f"[get_gmail_messages_content_batch] SSL error for message {mid} on final attempt: {ssl_error}"
)
return mid, None, ssl_error
except Exception as e:
return mid, None, e
# Process messages sequentially with small delays to prevent connection exhaustion
for mid in chunk_ids:
mid_result, msg_data, error = await fetch_message_with_retry(mid)
results[mid_result] = {"data": msg_data, "error": error}
# Brief delay between requests to allow connection cleanup
2025-07-23 14:01:02 -04:00
await asyncio.sleep(GMAIL_REQUEST_DELAY)
2025-06-18 17:31:57 -04:00
# Process results for this chunk
for mid in chunk_ids:
entry = results.get(mid, {"data": None, "error": "No result"})
2025-06-18 17:31:57 -04:00
if entry["error"]:
2025-07-17 14:46:25 -04:00
output_messages.append(f"⚠️ Message {mid}: {entry['error']}\n")
2025-06-18 17:31:57 -04:00
else:
message = entry["data"]
if not message:
2025-07-17 14:46:25 -04:00
output_messages.append(f"⚠️ Message {mid}: No data returned\n")
2025-06-18 17:31:57 -04:00
continue
2025-06-18 17:31:57 -04:00
# Extract content based on format
payload = message.get("payload", {})
2025-06-18 17:31:57 -04:00
if format == "metadata":
2025-12-27 12:08:58 -08:00
headers = _extract_headers(payload, GMAIL_METADATA_HEADERS)
2025-06-18 17:31:57 -04:00
subject = headers.get("Subject", "(no subject)")
sender = headers.get("From", "(unknown sender)")
to = headers.get("To", "")
cc = headers.get("Cc", "")
rfc822_msg_id = headers.get("Message-ID", "")
2025-12-13 13:49:28 -08:00
msg_output = (
f"Message ID: {mid}\nSubject: {subject}\nFrom: {sender}\n"
f"Date: {headers.get('Date', '(unknown date)')}\n"
2025-12-13 13:49:28 -08:00
)
if rfc822_msg_id:
msg_output += f"Message-ID: {rfc822_msg_id}\n"
if to:
msg_output += f"To: {to}\n"
if cc:
msg_output += f"Cc: {cc}\n"
msg_output += f"Web Link: {_generate_gmail_web_url(mid)}\n"
output_messages.append(msg_output)
else:
2025-06-18 17:31:57 -04:00
# Full format - extract body too
2025-12-27 12:08:58 -08:00
headers = _extract_headers(payload, GMAIL_METADATA_HEADERS)
2025-06-18 17:31:57 -04:00
subject = headers.get("Subject", "(no subject)")
sender = headers.get("From", "(unknown sender)")
to = headers.get("To", "")
cc = headers.get("Cc", "")
rfc822_msg_id = headers.get("Message-ID", "")
2025-08-13 12:05:06 -04:00
# Extract both text and HTML bodies using enhanced helper function
bodies = _extract_message_bodies(payload)
text_body = bodies.get("text", "")
html_body = bodies.get("html", "")
2025-08-13 12:05:06 -04:00
# Format body content with HTML fallback
2025-08-13 12:04:07 -04:00
body_data = _format_body_content(text_body, html_body)
2025-12-13 13:49:28 -08:00
msg_output = (
f"Message ID: {mid}\nSubject: {subject}\nFrom: {sender}\n"
f"Date: {headers.get('Date', '(unknown date)')}\n"
2025-12-13 13:49:28 -08:00
)
if rfc822_msg_id:
msg_output += f"Message-ID: {rfc822_msg_id}\n"
if to:
msg_output += f"To: {to}\n"
if cc:
msg_output += f"Cc: {cc}\n"
2025-12-13 13:49:28 -08:00
msg_output += (
f"Web Link: {_generate_gmail_web_url(mid)}\n\n{body_data}\n"
)
output_messages.append(msg_output)
2025-06-18 17:31:57 -04:00
# Combine all messages with separators
final_output = f"Retrieved {len(message_ids)} messages:\n\n"
final_output += "\n---\n\n".join(output_messages)
2025-06-18 17:31:57 -04:00
return final_output
2025-05-22 14:21:52 -04:00
2025-05-12 17:22:58 -04:00
@server.tool()
2025-12-13 13:49:28 -08:00
@handle_http_errors(
"get_gmail_attachment_content", is_read_only=True, service_type="gmail"
)
@require_google_service("gmail", "gmail_read")
async def get_gmail_attachment_content(
service,
message_id: str,
attachment_id: str,
user_google_email: str,
) -> str:
"""
2026-02-15 16:46:19 -05:00
Downloads an email attachment and saves it to local disk.
In stdio mode, returns the local file path for direct access.
In HTTP mode, returns a temporary download URL (valid for 1 hour).
May re-fetch message metadata to resolve filename and MIME type.
Args:
message_id (str): The ID of the Gmail message containing the attachment.
attachment_id (str): The ID of the attachment to download.
user_google_email (str): The user's Google email address. Required.
Returns:
2026-02-15 16:46:19 -05:00
str: Attachment metadata with either a local file path or download URL.
"""
logger.info(
f"[get_gmail_attachment_content] Invoked. Message ID: '{message_id}', Email: '{user_google_email}'"
)
2026-02-15 16:46:19 -05:00
# Download attachment content first, then optionally re-fetch message metadata
# to resolve filename and MIME type for the saved file.
try:
attachment = await asyncio.to_thread(
service.users()
.messages()
.attachments()
.get(userId="me", messageId=message_id, id=attachment_id)
.execute
)
except Exception as e:
2025-12-13 13:49:28 -08:00
logger.error(
f"[get_gmail_attachment_content] Failed to download attachment: {e}"
)
return (
f"Error: Failed to download attachment. The attachment ID may have changed.\n"
f"Please fetch the message content again to get an updated attachment ID.\n\n"
f"Error details: {str(e)}"
)
# Format response with attachment data
2025-12-13 13:49:28 -08:00
size_bytes = attachment.get("size", 0)
size_kb = size_bytes / 1024 if size_bytes else 0
2025-12-13 13:49:28 -08:00
base64_data = attachment.get("data", "")
Add HTTP URL-based attachment serving for Gmail attachments This commit implements a new feature that allows Gmail attachments to be served via HTTP URLs instead of returning base64-encoded data in the tool response. This avoids consuming LLM context window space and token budgets for large attachments. Architecture: ------------- The implementation works in both stdio and streamable-http transport modes: 1. Temp File Storage (core/attachment_storage.py): - New AttachmentStorage class manages temporary file storage in ./tmp/attachments/ - Uses UUID-based file IDs to prevent guessing/unauthorized access - Tracks metadata: filename, mime type, size, creation/expiration times - Files expire after 1 hour (configurable) with automatic cleanup support - Handles base64 decoding and file writing 2. HTTP Route Handlers: - Added /attachments/{file_id} route to main FastMCP server (streamable-http mode) - Added same route to MinimalOAuthServer (stdio mode) - Both routes serve files with proper Content-Type headers via FileResponse - Returns 404 for expired or missing attachments 3. Modified get_gmail_attachment_content(): - Now saves attachments to temp storage and returns HTTP URL - Attempts to fetch filename/mimeType from message metadata (best effort) - Handles stateless mode gracefully (skips file saving, shows preview) - Falls back to base64 preview if file saving fails - URL generation respects WORKSPACE_EXTERNAL_URL for reverse proxy setups Key Features: ------------- - Works in both stdio and streamable-http modes (uses existing HTTP servers) - Respects stateless mode (no file writes when WORKSPACE_MCP_STATELESS_MODE=true) - Secure: UUID-based file IDs prevent unauthorized access - Automatic expiration: Files cleaned up after 1 hour - Reverse proxy support: Uses WORKSPACE_EXTERNAL_URL if configured - Graceful degradation: Falls back to preview if storage fails Benefits: --------- - Avoids context window bloat: Large attachments don't consume LLM tokens - Better performance: Clients can stream/download files directly - More efficient: No need to decode base64 in client applications - Works across network boundaries: URLs accessible from any client The feature maintains backward compatibility - if file saving fails or stateless mode is enabled, the function falls back to showing a base64 preview.
2025-11-29 15:06:57 +01:00
# Check if we're in stateless mode (can't save files)
from auth.oauth_config import is_stateless_mode
2025-12-13 13:49:28 -08:00
Add HTTP URL-based attachment serving for Gmail attachments This commit implements a new feature that allows Gmail attachments to be served via HTTP URLs instead of returning base64-encoded data in the tool response. This avoids consuming LLM context window space and token budgets for large attachments. Architecture: ------------- The implementation works in both stdio and streamable-http transport modes: 1. Temp File Storage (core/attachment_storage.py): - New AttachmentStorage class manages temporary file storage in ./tmp/attachments/ - Uses UUID-based file IDs to prevent guessing/unauthorized access - Tracks metadata: filename, mime type, size, creation/expiration times - Files expire after 1 hour (configurable) with automatic cleanup support - Handles base64 decoding and file writing 2. HTTP Route Handlers: - Added /attachments/{file_id} route to main FastMCP server (streamable-http mode) - Added same route to MinimalOAuthServer (stdio mode) - Both routes serve files with proper Content-Type headers via FileResponse - Returns 404 for expired or missing attachments 3. Modified get_gmail_attachment_content(): - Now saves attachments to temp storage and returns HTTP URL - Attempts to fetch filename/mimeType from message metadata (best effort) - Handles stateless mode gracefully (skips file saving, shows preview) - Falls back to base64 preview if file saving fails - URL generation respects WORKSPACE_EXTERNAL_URL for reverse proxy setups Key Features: ------------- - Works in both stdio and streamable-http modes (uses existing HTTP servers) - Respects stateless mode (no file writes when WORKSPACE_MCP_STATELESS_MODE=true) - Secure: UUID-based file IDs prevent unauthorized access - Automatic expiration: Files cleaned up after 1 hour - Reverse proxy support: Uses WORKSPACE_EXTERNAL_URL if configured - Graceful degradation: Falls back to preview if storage fails Benefits: --------- - Avoids context window bloat: Large attachments don't consume LLM tokens - Better performance: Clients can stream/download files directly - More efficient: No need to decode base64 in client applications - Works across network boundaries: URLs accessible from any client The feature maintains backward compatibility - if file saving fails or stateless mode is enabled, the function falls back to showing a base64 preview.
2025-11-29 15:06:57 +01:00
if is_stateless_mode():
result_lines = [
"Attachment downloaded successfully!",
f"Message ID: {message_id}",
f"Size: {size_kb:.1f} KB ({size_bytes} bytes)",
"\n⚠️ Stateless mode: File storage disabled.",
"\nBase64-encoded content (first 100 characters shown):",
f"{base64_data[:100]}...",
2025-12-13 13:49:28 -08:00
"\nNote: Attachment IDs are ephemeral. Always use IDs from the most recent message fetch.",
Add HTTP URL-based attachment serving for Gmail attachments This commit implements a new feature that allows Gmail attachments to be served via HTTP URLs instead of returning base64-encoded data in the tool response. This avoids consuming LLM context window space and token budgets for large attachments. Architecture: ------------- The implementation works in both stdio and streamable-http transport modes: 1. Temp File Storage (core/attachment_storage.py): - New AttachmentStorage class manages temporary file storage in ./tmp/attachments/ - Uses UUID-based file IDs to prevent guessing/unauthorized access - Tracks metadata: filename, mime type, size, creation/expiration times - Files expire after 1 hour (configurable) with automatic cleanup support - Handles base64 decoding and file writing 2. HTTP Route Handlers: - Added /attachments/{file_id} route to main FastMCP server (streamable-http mode) - Added same route to MinimalOAuthServer (stdio mode) - Both routes serve files with proper Content-Type headers via FileResponse - Returns 404 for expired or missing attachments 3. Modified get_gmail_attachment_content(): - Now saves attachments to temp storage and returns HTTP URL - Attempts to fetch filename/mimeType from message metadata (best effort) - Handles stateless mode gracefully (skips file saving, shows preview) - Falls back to base64 preview if file saving fails - URL generation respects WORKSPACE_EXTERNAL_URL for reverse proxy setups Key Features: ------------- - Works in both stdio and streamable-http modes (uses existing HTTP servers) - Respects stateless mode (no file writes when WORKSPACE_MCP_STATELESS_MODE=true) - Secure: UUID-based file IDs prevent unauthorized access - Automatic expiration: Files cleaned up after 1 hour - Reverse proxy support: Uses WORKSPACE_EXTERNAL_URL if configured - Graceful degradation: Falls back to preview if storage fails Benefits: --------- - Avoids context window bloat: Large attachments don't consume LLM tokens - Better performance: Clients can stream/download files directly - More efficient: No need to decode base64 in client applications - Works across network boundaries: URLs accessible from any client The feature maintains backward compatibility - if file saving fails or stateless mode is enabled, the function falls back to showing a base64 preview.
2025-11-29 15:06:57 +01:00
]
2025-12-13 13:49:28 -08:00
logger.info(
f"[get_gmail_attachment_content] Successfully downloaded {size_kb:.1f} KB attachment (stateless mode)"
)
Add HTTP URL-based attachment serving for Gmail attachments This commit implements a new feature that allows Gmail attachments to be served via HTTP URLs instead of returning base64-encoded data in the tool response. This avoids consuming LLM context window space and token budgets for large attachments. Architecture: ------------- The implementation works in both stdio and streamable-http transport modes: 1. Temp File Storage (core/attachment_storage.py): - New AttachmentStorage class manages temporary file storage in ./tmp/attachments/ - Uses UUID-based file IDs to prevent guessing/unauthorized access - Tracks metadata: filename, mime type, size, creation/expiration times - Files expire after 1 hour (configurable) with automatic cleanup support - Handles base64 decoding and file writing 2. HTTP Route Handlers: - Added /attachments/{file_id} route to main FastMCP server (streamable-http mode) - Added same route to MinimalOAuthServer (stdio mode) - Both routes serve files with proper Content-Type headers via FileResponse - Returns 404 for expired or missing attachments 3. Modified get_gmail_attachment_content(): - Now saves attachments to temp storage and returns HTTP URL - Attempts to fetch filename/mimeType from message metadata (best effort) - Handles stateless mode gracefully (skips file saving, shows preview) - Falls back to base64 preview if file saving fails - URL generation respects WORKSPACE_EXTERNAL_URL for reverse proxy setups Key Features: ------------- - Works in both stdio and streamable-http modes (uses existing HTTP servers) - Respects stateless mode (no file writes when WORKSPACE_MCP_STATELESS_MODE=true) - Secure: UUID-based file IDs prevent unauthorized access - Automatic expiration: Files cleaned up after 1 hour - Reverse proxy support: Uses WORKSPACE_EXTERNAL_URL if configured - Graceful degradation: Falls back to preview if storage fails Benefits: --------- - Avoids context window bloat: Large attachments don't consume LLM tokens - Better performance: Clients can stream/download files directly - More efficient: No need to decode base64 in client applications - Works across network boundaries: URLs accessible from any client The feature maintains backward compatibility - if file saving fails or stateless mode is enabled, the function falls back to showing a base64 preview.
2025-11-29 15:06:57 +01:00
return "\n".join(result_lines)
# Save attachment to local disk and return file path
Add HTTP URL-based attachment serving for Gmail attachments This commit implements a new feature that allows Gmail attachments to be served via HTTP URLs instead of returning base64-encoded data in the tool response. This avoids consuming LLM context window space and token budgets for large attachments. Architecture: ------------- The implementation works in both stdio and streamable-http transport modes: 1. Temp File Storage (core/attachment_storage.py): - New AttachmentStorage class manages temporary file storage in ./tmp/attachments/ - Uses UUID-based file IDs to prevent guessing/unauthorized access - Tracks metadata: filename, mime type, size, creation/expiration times - Files expire after 1 hour (configurable) with automatic cleanup support - Handles base64 decoding and file writing 2. HTTP Route Handlers: - Added /attachments/{file_id} route to main FastMCP server (streamable-http mode) - Added same route to MinimalOAuthServer (stdio mode) - Both routes serve files with proper Content-Type headers via FileResponse - Returns 404 for expired or missing attachments 3. Modified get_gmail_attachment_content(): - Now saves attachments to temp storage and returns HTTP URL - Attempts to fetch filename/mimeType from message metadata (best effort) - Handles stateless mode gracefully (skips file saving, shows preview) - Falls back to base64 preview if file saving fails - URL generation respects WORKSPACE_EXTERNAL_URL for reverse proxy setups Key Features: ------------- - Works in both stdio and streamable-http modes (uses existing HTTP servers) - Respects stateless mode (no file writes when WORKSPACE_MCP_STATELESS_MODE=true) - Secure: UUID-based file IDs prevent unauthorized access - Automatic expiration: Files cleaned up after 1 hour - Reverse proxy support: Uses WORKSPACE_EXTERNAL_URL if configured - Graceful degradation: Falls back to preview if storage fails Benefits: --------- - Avoids context window bloat: Large attachments don't consume LLM tokens - Better performance: Clients can stream/download files directly - More efficient: No need to decode base64 in client applications - Works across network boundaries: URLs accessible from any client The feature maintains backward compatibility - if file saving fails or stateless mode is enabled, the function falls back to showing a base64 preview.
2025-11-29 15:06:57 +01:00
try:
2026-02-15 16:17:03 -05:00
from core.attachment_storage import get_attachment_storage, get_attachment_url
from core.config import get_transport_mode
2025-12-13 13:49:28 -08:00
Add HTTP URL-based attachment serving for Gmail attachments This commit implements a new feature that allows Gmail attachments to be served via HTTP URLs instead of returning base64-encoded data in the tool response. This avoids consuming LLM context window space and token budgets for large attachments. Architecture: ------------- The implementation works in both stdio and streamable-http transport modes: 1. Temp File Storage (core/attachment_storage.py): - New AttachmentStorage class manages temporary file storage in ./tmp/attachments/ - Uses UUID-based file IDs to prevent guessing/unauthorized access - Tracks metadata: filename, mime type, size, creation/expiration times - Files expire after 1 hour (configurable) with automatic cleanup support - Handles base64 decoding and file writing 2. HTTP Route Handlers: - Added /attachments/{file_id} route to main FastMCP server (streamable-http mode) - Added same route to MinimalOAuthServer (stdio mode) - Both routes serve files with proper Content-Type headers via FileResponse - Returns 404 for expired or missing attachments 3. Modified get_gmail_attachment_content(): - Now saves attachments to temp storage and returns HTTP URL - Attempts to fetch filename/mimeType from message metadata (best effort) - Handles stateless mode gracefully (skips file saving, shows preview) - Falls back to base64 preview if file saving fails - URL generation respects WORKSPACE_EXTERNAL_URL for reverse proxy setups Key Features: ------------- - Works in both stdio and streamable-http modes (uses existing HTTP servers) - Respects stateless mode (no file writes when WORKSPACE_MCP_STATELESS_MODE=true) - Secure: UUID-based file IDs prevent unauthorized access - Automatic expiration: Files cleaned up after 1 hour - Reverse proxy support: Uses WORKSPACE_EXTERNAL_URL if configured - Graceful degradation: Falls back to preview if storage fails Benefits: --------- - Avoids context window bloat: Large attachments don't consume LLM tokens - Better performance: Clients can stream/download files directly - More efficient: No need to decode base64 in client applications - Works across network boundaries: URLs accessible from any client The feature maintains backward compatibility - if file saving fails or stateless mode is enabled, the function falls back to showing a base64 preview.
2025-11-29 15:06:57 +01:00
storage = get_attachment_storage()
2025-12-13 13:49:28 -08:00
# Try to get filename and mime type from message
Add HTTP URL-based attachment serving for Gmail attachments This commit implements a new feature that allows Gmail attachments to be served via HTTP URLs instead of returning base64-encoded data in the tool response. This avoids consuming LLM context window space and token budgets for large attachments. Architecture: ------------- The implementation works in both stdio and streamable-http transport modes: 1. Temp File Storage (core/attachment_storage.py): - New AttachmentStorage class manages temporary file storage in ./tmp/attachments/ - Uses UUID-based file IDs to prevent guessing/unauthorized access - Tracks metadata: filename, mime type, size, creation/expiration times - Files expire after 1 hour (configurable) with automatic cleanup support - Handles base64 decoding and file writing 2. HTTP Route Handlers: - Added /attachments/{file_id} route to main FastMCP server (streamable-http mode) - Added same route to MinimalOAuthServer (stdio mode) - Both routes serve files with proper Content-Type headers via FileResponse - Returns 404 for expired or missing attachments 3. Modified get_gmail_attachment_content(): - Now saves attachments to temp storage and returns HTTP URL - Attempts to fetch filename/mimeType from message metadata (best effort) - Handles stateless mode gracefully (skips file saving, shows preview) - Falls back to base64 preview if file saving fails - URL generation respects WORKSPACE_EXTERNAL_URL for reverse proxy setups Key Features: ------------- - Works in both stdio and streamable-http modes (uses existing HTTP servers) - Respects stateless mode (no file writes when WORKSPACE_MCP_STATELESS_MODE=true) - Secure: UUID-based file IDs prevent unauthorized access - Automatic expiration: Files cleaned up after 1 hour - Reverse proxy support: Uses WORKSPACE_EXTERNAL_URL if configured - Graceful degradation: Falls back to preview if storage fails Benefits: --------- - Avoids context window bloat: Large attachments don't consume LLM tokens - Better performance: Clients can stream/download files directly - More efficient: No need to decode base64 in client applications - Works across network boundaries: URLs accessible from any client The feature maintains backward compatibility - if file saving fails or stateless mode is enabled, the function falls back to showing a base64 preview.
2025-11-29 15:06:57 +01:00
filename = None
mime_type = None
try:
2026-02-15 16:46:19 -05:00
# Use format="full" with fields to limit response to attachment metadata only
message_full = await asyncio.to_thread(
Add HTTP URL-based attachment serving for Gmail attachments This commit implements a new feature that allows Gmail attachments to be served via HTTP URLs instead of returning base64-encoded data in the tool response. This avoids consuming LLM context window space and token budgets for large attachments. Architecture: ------------- The implementation works in both stdio and streamable-http transport modes: 1. Temp File Storage (core/attachment_storage.py): - New AttachmentStorage class manages temporary file storage in ./tmp/attachments/ - Uses UUID-based file IDs to prevent guessing/unauthorized access - Tracks metadata: filename, mime type, size, creation/expiration times - Files expire after 1 hour (configurable) with automatic cleanup support - Handles base64 decoding and file writing 2. HTTP Route Handlers: - Added /attachments/{file_id} route to main FastMCP server (streamable-http mode) - Added same route to MinimalOAuthServer (stdio mode) - Both routes serve files with proper Content-Type headers via FileResponse - Returns 404 for expired or missing attachments 3. Modified get_gmail_attachment_content(): - Now saves attachments to temp storage and returns HTTP URL - Attempts to fetch filename/mimeType from message metadata (best effort) - Handles stateless mode gracefully (skips file saving, shows preview) - Falls back to base64 preview if file saving fails - URL generation respects WORKSPACE_EXTERNAL_URL for reverse proxy setups Key Features: ------------- - Works in both stdio and streamable-http modes (uses existing HTTP servers) - Respects stateless mode (no file writes when WORKSPACE_MCP_STATELESS_MODE=true) - Secure: UUID-based file IDs prevent unauthorized access - Automatic expiration: Files cleaned up after 1 hour - Reverse proxy support: Uses WORKSPACE_EXTERNAL_URL if configured - Graceful degradation: Falls back to preview if storage fails Benefits: --------- - Avoids context window bloat: Large attachments don't consume LLM tokens - Better performance: Clients can stream/download files directly - More efficient: No need to decode base64 in client applications - Works across network boundaries: URLs accessible from any client The feature maintains backward compatibility - if file saving fails or stateless mode is enabled, the function falls back to showing a base64 preview.
2025-11-29 15:06:57 +01:00
service.users()
.messages()
2026-02-15 16:46:19 -05:00
.get(
userId="me",
id=message_id,
format="full",
fields="payload(parts(filename,mimeType,body(attachmentId,size)),body(attachmentId,size),filename,mimeType)",
)
2025-12-08 09:22:28 -05:00
.execute
Add HTTP URL-based attachment serving for Gmail attachments This commit implements a new feature that allows Gmail attachments to be served via HTTP URLs instead of returning base64-encoded data in the tool response. This avoids consuming LLM context window space and token budgets for large attachments. Architecture: ------------- The implementation works in both stdio and streamable-http transport modes: 1. Temp File Storage (core/attachment_storage.py): - New AttachmentStorage class manages temporary file storage in ./tmp/attachments/ - Uses UUID-based file IDs to prevent guessing/unauthorized access - Tracks metadata: filename, mime type, size, creation/expiration times - Files expire after 1 hour (configurable) with automatic cleanup support - Handles base64 decoding and file writing 2. HTTP Route Handlers: - Added /attachments/{file_id} route to main FastMCP server (streamable-http mode) - Added same route to MinimalOAuthServer (stdio mode) - Both routes serve files with proper Content-Type headers via FileResponse - Returns 404 for expired or missing attachments 3. Modified get_gmail_attachment_content(): - Now saves attachments to temp storage and returns HTTP URL - Attempts to fetch filename/mimeType from message metadata (best effort) - Handles stateless mode gracefully (skips file saving, shows preview) - Falls back to base64 preview if file saving fails - URL generation respects WORKSPACE_EXTERNAL_URL for reverse proxy setups Key Features: ------------- - Works in both stdio and streamable-http modes (uses existing HTTP servers) - Respects stateless mode (no file writes when WORKSPACE_MCP_STATELESS_MODE=true) - Secure: UUID-based file IDs prevent unauthorized access - Automatic expiration: Files cleaned up after 1 hour - Reverse proxy support: Uses WORKSPACE_EXTERNAL_URL if configured - Graceful degradation: Falls back to preview if storage fails Benefits: --------- - Avoids context window bloat: Large attachments don't consume LLM tokens - Better performance: Clients can stream/download files directly - More efficient: No need to decode base64 in client applications - Works across network boundaries: URLs accessible from any client The feature maintains backward compatibility - if file saving fails or stateless mode is enabled, the function falls back to showing a base64 preview.
2025-11-29 15:06:57 +01:00
)
payload = message_full.get("payload", {})
Add HTTP URL-based attachment serving for Gmail attachments This commit implements a new feature that allows Gmail attachments to be served via HTTP URLs instead of returning base64-encoded data in the tool response. This avoids consuming LLM context window space and token budgets for large attachments. Architecture: ------------- The implementation works in both stdio and streamable-http transport modes: 1. Temp File Storage (core/attachment_storage.py): - New AttachmentStorage class manages temporary file storage in ./tmp/attachments/ - Uses UUID-based file IDs to prevent guessing/unauthorized access - Tracks metadata: filename, mime type, size, creation/expiration times - Files expire after 1 hour (configurable) with automatic cleanup support - Handles base64 decoding and file writing 2. HTTP Route Handlers: - Added /attachments/{file_id} route to main FastMCP server (streamable-http mode) - Added same route to MinimalOAuthServer (stdio mode) - Both routes serve files with proper Content-Type headers via FileResponse - Returns 404 for expired or missing attachments 3. Modified get_gmail_attachment_content(): - Now saves attachments to temp storage and returns HTTP URL - Attempts to fetch filename/mimeType from message metadata (best effort) - Handles stateless mode gracefully (skips file saving, shows preview) - Falls back to base64 preview if file saving fails - URL generation respects WORKSPACE_EXTERNAL_URL for reverse proxy setups Key Features: ------------- - Works in both stdio and streamable-http modes (uses existing HTTP servers) - Respects stateless mode (no file writes when WORKSPACE_MCP_STATELESS_MODE=true) - Secure: UUID-based file IDs prevent unauthorized access - Automatic expiration: Files cleaned up after 1 hour - Reverse proxy support: Uses WORKSPACE_EXTERNAL_URL if configured - Graceful degradation: Falls back to preview if storage fails Benefits: --------- - Avoids context window bloat: Large attachments don't consume LLM tokens - Better performance: Clients can stream/download files directly - More efficient: No need to decode base64 in client applications - Works across network boundaries: URLs accessible from any client The feature maintains backward compatibility - if file saving fails or stateless mode is enabled, the function falls back to showing a base64 preview.
2025-11-29 15:06:57 +01:00
attachments = _extract_attachments(payload)
# First try exact attachmentId match
Add HTTP URL-based attachment serving for Gmail attachments This commit implements a new feature that allows Gmail attachments to be served via HTTP URLs instead of returning base64-encoded data in the tool response. This avoids consuming LLM context window space and token budgets for large attachments. Architecture: ------------- The implementation works in both stdio and streamable-http transport modes: 1. Temp File Storage (core/attachment_storage.py): - New AttachmentStorage class manages temporary file storage in ./tmp/attachments/ - Uses UUID-based file IDs to prevent guessing/unauthorized access - Tracks metadata: filename, mime type, size, creation/expiration times - Files expire after 1 hour (configurable) with automatic cleanup support - Handles base64 decoding and file writing 2. HTTP Route Handlers: - Added /attachments/{file_id} route to main FastMCP server (streamable-http mode) - Added same route to MinimalOAuthServer (stdio mode) - Both routes serve files with proper Content-Type headers via FileResponse - Returns 404 for expired or missing attachments 3. Modified get_gmail_attachment_content(): - Now saves attachments to temp storage and returns HTTP URL - Attempts to fetch filename/mimeType from message metadata (best effort) - Handles stateless mode gracefully (skips file saving, shows preview) - Falls back to base64 preview if file saving fails - URL generation respects WORKSPACE_EXTERNAL_URL for reverse proxy setups Key Features: ------------- - Works in both stdio and streamable-http modes (uses existing HTTP servers) - Respects stateless mode (no file writes when WORKSPACE_MCP_STATELESS_MODE=true) - Secure: UUID-based file IDs prevent unauthorized access - Automatic expiration: Files cleaned up after 1 hour - Reverse proxy support: Uses WORKSPACE_EXTERNAL_URL if configured - Graceful degradation: Falls back to preview if storage fails Benefits: --------- - Avoids context window bloat: Large attachments don't consume LLM tokens - Better performance: Clients can stream/download files directly - More efficient: No need to decode base64 in client applications - Works across network boundaries: URLs accessible from any client The feature maintains backward compatibility - if file saving fails or stateless mode is enabled, the function falls back to showing a base64 preview.
2025-11-29 15:06:57 +01:00
for att in attachments:
if att.get("attachmentId") == attachment_id:
filename = att.get("filename")
mime_type = att.get("mimeType")
break
2026-02-15 16:28:34 -05:00
# Fallback: match by size if exactly one attachment matches (IDs are ephemeral)
if not filename and attachments:
2026-02-15 16:28:34 -05:00
size_matches = [
2026-02-15 16:46:28 -05:00
att
for att in attachments
2026-02-15 16:28:34 -05:00
if att.get("size") and abs(att["size"] - size_bytes) < 100
]
if len(size_matches) == 1:
filename = size_matches[0].get("filename")
mime_type = size_matches[0].get("mimeType")
logger.warning(
f"Attachment {attachment_id} matched by size fallback as '{filename}'"
)
# Last resort: if only one attachment, use its name
if not filename and len(attachments) == 1:
filename = attachments[0].get("filename")
mime_type = attachments[0].get("mimeType")
Add HTTP URL-based attachment serving for Gmail attachments This commit implements a new feature that allows Gmail attachments to be served via HTTP URLs instead of returning base64-encoded data in the tool response. This avoids consuming LLM context window space and token budgets for large attachments. Architecture: ------------- The implementation works in both stdio and streamable-http transport modes: 1. Temp File Storage (core/attachment_storage.py): - New AttachmentStorage class manages temporary file storage in ./tmp/attachments/ - Uses UUID-based file IDs to prevent guessing/unauthorized access - Tracks metadata: filename, mime type, size, creation/expiration times - Files expire after 1 hour (configurable) with automatic cleanup support - Handles base64 decoding and file writing 2. HTTP Route Handlers: - Added /attachments/{file_id} route to main FastMCP server (streamable-http mode) - Added same route to MinimalOAuthServer (stdio mode) - Both routes serve files with proper Content-Type headers via FileResponse - Returns 404 for expired or missing attachments 3. Modified get_gmail_attachment_content(): - Now saves attachments to temp storage and returns HTTP URL - Attempts to fetch filename/mimeType from message metadata (best effort) - Handles stateless mode gracefully (skips file saving, shows preview) - Falls back to base64 preview if file saving fails - URL generation respects WORKSPACE_EXTERNAL_URL for reverse proxy setups Key Features: ------------- - Works in both stdio and streamable-http modes (uses existing HTTP servers) - Respects stateless mode (no file writes when WORKSPACE_MCP_STATELESS_MODE=true) - Secure: UUID-based file IDs prevent unauthorized access - Automatic expiration: Files cleaned up after 1 hour - Reverse proxy support: Uses WORKSPACE_EXTERNAL_URL if configured - Graceful degradation: Falls back to preview if storage fails Benefits: --------- - Avoids context window bloat: Large attachments don't consume LLM tokens - Better performance: Clients can stream/download files directly - More efficient: No need to decode base64 in client applications - Works across network boundaries: URLs accessible from any client The feature maintains backward compatibility - if file saving fails or stateless mode is enabled, the function falls back to showing a base64 preview.
2025-11-29 15:06:57 +01:00
except Exception:
2025-12-13 13:49:28 -08:00
logger.debug(
f"Could not fetch attachment metadata for {attachment_id}, using defaults"
)
Add HTTP URL-based attachment serving for Gmail attachments This commit implements a new feature that allows Gmail attachments to be served via HTTP URLs instead of returning base64-encoded data in the tool response. This avoids consuming LLM context window space and token budgets for large attachments. Architecture: ------------- The implementation works in both stdio and streamable-http transport modes: 1. Temp File Storage (core/attachment_storage.py): - New AttachmentStorage class manages temporary file storage in ./tmp/attachments/ - Uses UUID-based file IDs to prevent guessing/unauthorized access - Tracks metadata: filename, mime type, size, creation/expiration times - Files expire after 1 hour (configurable) with automatic cleanup support - Handles base64 decoding and file writing 2. HTTP Route Handlers: - Added /attachments/{file_id} route to main FastMCP server (streamable-http mode) - Added same route to MinimalOAuthServer (stdio mode) - Both routes serve files with proper Content-Type headers via FileResponse - Returns 404 for expired or missing attachments 3. Modified get_gmail_attachment_content(): - Now saves attachments to temp storage and returns HTTP URL - Attempts to fetch filename/mimeType from message metadata (best effort) - Handles stateless mode gracefully (skips file saving, shows preview) - Falls back to base64 preview if file saving fails - URL generation respects WORKSPACE_EXTERNAL_URL for reverse proxy setups Key Features: ------------- - Works in both stdio and streamable-http modes (uses existing HTTP servers) - Respects stateless mode (no file writes when WORKSPACE_MCP_STATELESS_MODE=true) - Secure: UUID-based file IDs prevent unauthorized access - Automatic expiration: Files cleaned up after 1 hour - Reverse proxy support: Uses WORKSPACE_EXTERNAL_URL if configured - Graceful degradation: Falls back to preview if storage fails Benefits: --------- - Avoids context window bloat: Large attachments don't consume LLM tokens - Better performance: Clients can stream/download files directly - More efficient: No need to decode base64 in client applications - Works across network boundaries: URLs accessible from any client The feature maintains backward compatibility - if file saving fails or stateless mode is enabled, the function falls back to showing a base64 preview.
2025-11-29 15:06:57 +01:00
2026-02-15 16:17:03 -05:00
# Save attachment to local disk
result = storage.save_attachment(
2025-12-13 13:49:28 -08:00
base64_data=base64_data, filename=filename, mime_type=mime_type
Add HTTP URL-based attachment serving for Gmail attachments This commit implements a new feature that allows Gmail attachments to be served via HTTP URLs instead of returning base64-encoded data in the tool response. This avoids consuming LLM context window space and token budgets for large attachments. Architecture: ------------- The implementation works in both stdio and streamable-http transport modes: 1. Temp File Storage (core/attachment_storage.py): - New AttachmentStorage class manages temporary file storage in ./tmp/attachments/ - Uses UUID-based file IDs to prevent guessing/unauthorized access - Tracks metadata: filename, mime type, size, creation/expiration times - Files expire after 1 hour (configurable) with automatic cleanup support - Handles base64 decoding and file writing 2. HTTP Route Handlers: - Added /attachments/{file_id} route to main FastMCP server (streamable-http mode) - Added same route to MinimalOAuthServer (stdio mode) - Both routes serve files with proper Content-Type headers via FileResponse - Returns 404 for expired or missing attachments 3. Modified get_gmail_attachment_content(): - Now saves attachments to temp storage and returns HTTP URL - Attempts to fetch filename/mimeType from message metadata (best effort) - Handles stateless mode gracefully (skips file saving, shows preview) - Falls back to base64 preview if file saving fails - URL generation respects WORKSPACE_EXTERNAL_URL for reverse proxy setups Key Features: ------------- - Works in both stdio and streamable-http modes (uses existing HTTP servers) - Respects stateless mode (no file writes when WORKSPACE_MCP_STATELESS_MODE=true) - Secure: UUID-based file IDs prevent unauthorized access - Automatic expiration: Files cleaned up after 1 hour - Reverse proxy support: Uses WORKSPACE_EXTERNAL_URL if configured - Graceful degradation: Falls back to preview if storage fails Benefits: --------- - Avoids context window bloat: Large attachments don't consume LLM tokens - Better performance: Clients can stream/download files directly - More efficient: No need to decode base64 in client applications - Works across network boundaries: URLs accessible from any client The feature maintains backward compatibility - if file saving fails or stateless mode is enabled, the function falls back to showing a base64 preview.
2025-11-29 15:06:57 +01:00
)
2025-12-13 13:49:28 -08:00
Add HTTP URL-based attachment serving for Gmail attachments This commit implements a new feature that allows Gmail attachments to be served via HTTP URLs instead of returning base64-encoded data in the tool response. This avoids consuming LLM context window space and token budgets for large attachments. Architecture: ------------- The implementation works in both stdio and streamable-http transport modes: 1. Temp File Storage (core/attachment_storage.py): - New AttachmentStorage class manages temporary file storage in ./tmp/attachments/ - Uses UUID-based file IDs to prevent guessing/unauthorized access - Tracks metadata: filename, mime type, size, creation/expiration times - Files expire after 1 hour (configurable) with automatic cleanup support - Handles base64 decoding and file writing 2. HTTP Route Handlers: - Added /attachments/{file_id} route to main FastMCP server (streamable-http mode) - Added same route to MinimalOAuthServer (stdio mode) - Both routes serve files with proper Content-Type headers via FileResponse - Returns 404 for expired or missing attachments 3. Modified get_gmail_attachment_content(): - Now saves attachments to temp storage and returns HTTP URL - Attempts to fetch filename/mimeType from message metadata (best effort) - Handles stateless mode gracefully (skips file saving, shows preview) - Falls back to base64 preview if file saving fails - URL generation respects WORKSPACE_EXTERNAL_URL for reverse proxy setups Key Features: ------------- - Works in both stdio and streamable-http modes (uses existing HTTP servers) - Respects stateless mode (no file writes when WORKSPACE_MCP_STATELESS_MODE=true) - Secure: UUID-based file IDs prevent unauthorized access - Automatic expiration: Files cleaned up after 1 hour - Reverse proxy support: Uses WORKSPACE_EXTERNAL_URL if configured - Graceful degradation: Falls back to preview if storage fails Benefits: --------- - Avoids context window bloat: Large attachments don't consume LLM tokens - Better performance: Clients can stream/download files directly - More efficient: No need to decode base64 in client applications - Works across network boundaries: URLs accessible from any client The feature maintains backward compatibility - if file saving fails or stateless mode is enabled, the function falls back to showing a base64 preview.
2025-11-29 15:06:57 +01:00
result_lines = [
2026-02-15 16:17:03 -05:00
"Attachment downloaded successfully!",
Add HTTP URL-based attachment serving for Gmail attachments This commit implements a new feature that allows Gmail attachments to be served via HTTP URLs instead of returning base64-encoded data in the tool response. This avoids consuming LLM context window space and token budgets for large attachments. Architecture: ------------- The implementation works in both stdio and streamable-http transport modes: 1. Temp File Storage (core/attachment_storage.py): - New AttachmentStorage class manages temporary file storage in ./tmp/attachments/ - Uses UUID-based file IDs to prevent guessing/unauthorized access - Tracks metadata: filename, mime type, size, creation/expiration times - Files expire after 1 hour (configurable) with automatic cleanup support - Handles base64 decoding and file writing 2. HTTP Route Handlers: - Added /attachments/{file_id} route to main FastMCP server (streamable-http mode) - Added same route to MinimalOAuthServer (stdio mode) - Both routes serve files with proper Content-Type headers via FileResponse - Returns 404 for expired or missing attachments 3. Modified get_gmail_attachment_content(): - Now saves attachments to temp storage and returns HTTP URL - Attempts to fetch filename/mimeType from message metadata (best effort) - Handles stateless mode gracefully (skips file saving, shows preview) - Falls back to base64 preview if file saving fails - URL generation respects WORKSPACE_EXTERNAL_URL for reverse proxy setups Key Features: ------------- - Works in both stdio and streamable-http modes (uses existing HTTP servers) - Respects stateless mode (no file writes when WORKSPACE_MCP_STATELESS_MODE=true) - Secure: UUID-based file IDs prevent unauthorized access - Automatic expiration: Files cleaned up after 1 hour - Reverse proxy support: Uses WORKSPACE_EXTERNAL_URL if configured - Graceful degradation: Falls back to preview if storage fails Benefits: --------- - Avoids context window bloat: Large attachments don't consume LLM tokens - Better performance: Clients can stream/download files directly - More efficient: No need to decode base64 in client applications - Works across network boundaries: URLs accessible from any client The feature maintains backward compatibility - if file saving fails or stateless mode is enabled, the function falls back to showing a base64 preview.
2025-11-29 15:06:57 +01:00
f"Message ID: {message_id}",
f"Filename: {filename or 'unknown'}",
Add HTTP URL-based attachment serving for Gmail attachments This commit implements a new feature that allows Gmail attachments to be served via HTTP URLs instead of returning base64-encoded data in the tool response. This avoids consuming LLM context window space and token budgets for large attachments. Architecture: ------------- The implementation works in both stdio and streamable-http transport modes: 1. Temp File Storage (core/attachment_storage.py): - New AttachmentStorage class manages temporary file storage in ./tmp/attachments/ - Uses UUID-based file IDs to prevent guessing/unauthorized access - Tracks metadata: filename, mime type, size, creation/expiration times - Files expire after 1 hour (configurable) with automatic cleanup support - Handles base64 decoding and file writing 2. HTTP Route Handlers: - Added /attachments/{file_id} route to main FastMCP server (streamable-http mode) - Added same route to MinimalOAuthServer (stdio mode) - Both routes serve files with proper Content-Type headers via FileResponse - Returns 404 for expired or missing attachments 3. Modified get_gmail_attachment_content(): - Now saves attachments to temp storage and returns HTTP URL - Attempts to fetch filename/mimeType from message metadata (best effort) - Handles stateless mode gracefully (skips file saving, shows preview) - Falls back to base64 preview if file saving fails - URL generation respects WORKSPACE_EXTERNAL_URL for reverse proxy setups Key Features: ------------- - Works in both stdio and streamable-http modes (uses existing HTTP servers) - Respects stateless mode (no file writes when WORKSPACE_MCP_STATELESS_MODE=true) - Secure: UUID-based file IDs prevent unauthorized access - Automatic expiration: Files cleaned up after 1 hour - Reverse proxy support: Uses WORKSPACE_EXTERNAL_URL if configured - Graceful degradation: Falls back to preview if storage fails Benefits: --------- - Avoids context window bloat: Large attachments don't consume LLM tokens - Better performance: Clients can stream/download files directly - More efficient: No need to decode base64 in client applications - Works across network boundaries: URLs accessible from any client The feature maintains backward compatibility - if file saving fails or stateless mode is enabled, the function falls back to showing a base64 preview.
2025-11-29 15:06:57 +01:00
f"Size: {size_kb:.1f} KB ({size_bytes} bytes)",
]
2025-12-13 13:49:28 -08:00
2026-02-15 16:17:03 -05:00
if get_transport_mode() == "stdio":
result_lines.append(f"\n📎 Saved to: {result.path}")
result_lines.append(
"\nThe file has been saved to disk and can be accessed directly via the file path."
)
else:
download_url = get_attachment_url(result.file_id)
result_lines.append(f"\n📎 Download URL: {download_url}")
result_lines.append("\nThe file will expire after 1 hour.")
result_lines.append(
"\nNote: Attachment IDs are ephemeral. Always use IDs from the most recent message fetch."
)
2025-12-13 13:49:28 -08:00
logger.info(
2026-02-15 16:17:03 -05:00
f"[get_gmail_attachment_content] Successfully saved {size_kb:.1f} KB attachment to {result.path}"
2025-12-13 13:49:28 -08:00
)
Add HTTP URL-based attachment serving for Gmail attachments This commit implements a new feature that allows Gmail attachments to be served via HTTP URLs instead of returning base64-encoded data in the tool response. This avoids consuming LLM context window space and token budgets for large attachments. Architecture: ------------- The implementation works in both stdio and streamable-http transport modes: 1. Temp File Storage (core/attachment_storage.py): - New AttachmentStorage class manages temporary file storage in ./tmp/attachments/ - Uses UUID-based file IDs to prevent guessing/unauthorized access - Tracks metadata: filename, mime type, size, creation/expiration times - Files expire after 1 hour (configurable) with automatic cleanup support - Handles base64 decoding and file writing 2. HTTP Route Handlers: - Added /attachments/{file_id} route to main FastMCP server (streamable-http mode) - Added same route to MinimalOAuthServer (stdio mode) - Both routes serve files with proper Content-Type headers via FileResponse - Returns 404 for expired or missing attachments 3. Modified get_gmail_attachment_content(): - Now saves attachments to temp storage and returns HTTP URL - Attempts to fetch filename/mimeType from message metadata (best effort) - Handles stateless mode gracefully (skips file saving, shows preview) - Falls back to base64 preview if file saving fails - URL generation respects WORKSPACE_EXTERNAL_URL for reverse proxy setups Key Features: ------------- - Works in both stdio and streamable-http modes (uses existing HTTP servers) - Respects stateless mode (no file writes when WORKSPACE_MCP_STATELESS_MODE=true) - Secure: UUID-based file IDs prevent unauthorized access - Automatic expiration: Files cleaned up after 1 hour - Reverse proxy support: Uses WORKSPACE_EXTERNAL_URL if configured - Graceful degradation: Falls back to preview if storage fails Benefits: --------- - Avoids context window bloat: Large attachments don't consume LLM tokens - Better performance: Clients can stream/download files directly - More efficient: No need to decode base64 in client applications - Works across network boundaries: URLs accessible from any client The feature maintains backward compatibility - if file saving fails or stateless mode is enabled, the function falls back to showing a base64 preview.
2025-11-29 15:06:57 +01:00
return "\n".join(result_lines)
2025-12-13 13:49:28 -08:00
Add HTTP URL-based attachment serving for Gmail attachments This commit implements a new feature that allows Gmail attachments to be served via HTTP URLs instead of returning base64-encoded data in the tool response. This avoids consuming LLM context window space and token budgets for large attachments. Architecture: ------------- The implementation works in both stdio and streamable-http transport modes: 1. Temp File Storage (core/attachment_storage.py): - New AttachmentStorage class manages temporary file storage in ./tmp/attachments/ - Uses UUID-based file IDs to prevent guessing/unauthorized access - Tracks metadata: filename, mime type, size, creation/expiration times - Files expire after 1 hour (configurable) with automatic cleanup support - Handles base64 decoding and file writing 2. HTTP Route Handlers: - Added /attachments/{file_id} route to main FastMCP server (streamable-http mode) - Added same route to MinimalOAuthServer (stdio mode) - Both routes serve files with proper Content-Type headers via FileResponse - Returns 404 for expired or missing attachments 3. Modified get_gmail_attachment_content(): - Now saves attachments to temp storage and returns HTTP URL - Attempts to fetch filename/mimeType from message metadata (best effort) - Handles stateless mode gracefully (skips file saving, shows preview) - Falls back to base64 preview if file saving fails - URL generation respects WORKSPACE_EXTERNAL_URL for reverse proxy setups Key Features: ------------- - Works in both stdio and streamable-http modes (uses existing HTTP servers) - Respects stateless mode (no file writes when WORKSPACE_MCP_STATELESS_MODE=true) - Secure: UUID-based file IDs prevent unauthorized access - Automatic expiration: Files cleaned up after 1 hour - Reverse proxy support: Uses WORKSPACE_EXTERNAL_URL if configured - Graceful degradation: Falls back to preview if storage fails Benefits: --------- - Avoids context window bloat: Large attachments don't consume LLM tokens - Better performance: Clients can stream/download files directly - More efficient: No need to decode base64 in client applications - Works across network boundaries: URLs accessible from any client The feature maintains backward compatibility - if file saving fails or stateless mode is enabled, the function falls back to showing a base64 preview.
2025-11-29 15:06:57 +01:00
except Exception as e:
2025-12-13 13:49:28 -08:00
logger.error(
f"[get_gmail_attachment_content] Failed to save attachment: {e}",
exc_info=True,
)
Add HTTP URL-based attachment serving for Gmail attachments This commit implements a new feature that allows Gmail attachments to be served via HTTP URLs instead of returning base64-encoded data in the tool response. This avoids consuming LLM context window space and token budgets for large attachments. Architecture: ------------- The implementation works in both stdio and streamable-http transport modes: 1. Temp File Storage (core/attachment_storage.py): - New AttachmentStorage class manages temporary file storage in ./tmp/attachments/ - Uses UUID-based file IDs to prevent guessing/unauthorized access - Tracks metadata: filename, mime type, size, creation/expiration times - Files expire after 1 hour (configurable) with automatic cleanup support - Handles base64 decoding and file writing 2. HTTP Route Handlers: - Added /attachments/{file_id} route to main FastMCP server (streamable-http mode) - Added same route to MinimalOAuthServer (stdio mode) - Both routes serve files with proper Content-Type headers via FileResponse - Returns 404 for expired or missing attachments 3. Modified get_gmail_attachment_content(): - Now saves attachments to temp storage and returns HTTP URL - Attempts to fetch filename/mimeType from message metadata (best effort) - Handles stateless mode gracefully (skips file saving, shows preview) - Falls back to base64 preview if file saving fails - URL generation respects WORKSPACE_EXTERNAL_URL for reverse proxy setups Key Features: ------------- - Works in both stdio and streamable-http modes (uses existing HTTP servers) - Respects stateless mode (no file writes when WORKSPACE_MCP_STATELESS_MODE=true) - Secure: UUID-based file IDs prevent unauthorized access - Automatic expiration: Files cleaned up after 1 hour - Reverse proxy support: Uses WORKSPACE_EXTERNAL_URL if configured - Graceful degradation: Falls back to preview if storage fails Benefits: --------- - Avoids context window bloat: Large attachments don't consume LLM tokens - Better performance: Clients can stream/download files directly - More efficient: No need to decode base64 in client applications - Works across network boundaries: URLs accessible from any client The feature maintains backward compatibility - if file saving fails or stateless mode is enabled, the function falls back to showing a base64 preview.
2025-11-29 15:06:57 +01:00
# Fallback to showing base64 preview
result_lines = [
"Attachment downloaded successfully!",
f"Message ID: {message_id}",
f"Size: {size_kb:.1f} KB ({size_bytes} bytes)",
"\n⚠️ Failed to save attachment file. Showing preview instead.",
"\nBase64-encoded content (first 100 characters shown):",
f"{base64_data[:100]}...",
f"\nError: {str(e)}",
2025-12-13 13:49:28 -08:00
"\nNote: Attachment IDs are ephemeral. Always use IDs from the most recent message fetch.",
Add HTTP URL-based attachment serving for Gmail attachments This commit implements a new feature that allows Gmail attachments to be served via HTTP URLs instead of returning base64-encoded data in the tool response. This avoids consuming LLM context window space and token budgets for large attachments. Architecture: ------------- The implementation works in both stdio and streamable-http transport modes: 1. Temp File Storage (core/attachment_storage.py): - New AttachmentStorage class manages temporary file storage in ./tmp/attachments/ - Uses UUID-based file IDs to prevent guessing/unauthorized access - Tracks metadata: filename, mime type, size, creation/expiration times - Files expire after 1 hour (configurable) with automatic cleanup support - Handles base64 decoding and file writing 2. HTTP Route Handlers: - Added /attachments/{file_id} route to main FastMCP server (streamable-http mode) - Added same route to MinimalOAuthServer (stdio mode) - Both routes serve files with proper Content-Type headers via FileResponse - Returns 404 for expired or missing attachments 3. Modified get_gmail_attachment_content(): - Now saves attachments to temp storage and returns HTTP URL - Attempts to fetch filename/mimeType from message metadata (best effort) - Handles stateless mode gracefully (skips file saving, shows preview) - Falls back to base64 preview if file saving fails - URL generation respects WORKSPACE_EXTERNAL_URL for reverse proxy setups Key Features: ------------- - Works in both stdio and streamable-http modes (uses existing HTTP servers) - Respects stateless mode (no file writes when WORKSPACE_MCP_STATELESS_MODE=true) - Secure: UUID-based file IDs prevent unauthorized access - Automatic expiration: Files cleaned up after 1 hour - Reverse proxy support: Uses WORKSPACE_EXTERNAL_URL if configured - Graceful degradation: Falls back to preview if storage fails Benefits: --------- - Avoids context window bloat: Large attachments don't consume LLM tokens - Better performance: Clients can stream/download files directly - More efficient: No need to decode base64 in client applications - Works across network boundaries: URLs accessible from any client The feature maintains backward compatibility - if file saving fails or stateless mode is enabled, the function falls back to showing a base64 preview.
2025-11-29 15:06:57 +01:00
]
return "\n".join(result_lines)
@server.tool()
@handle_http_errors("send_gmail_message", service_type="gmail")
@require_google_service("gmail", GMAIL_SEND_SCOPE)
async def send_gmail_message(
service,
2025-05-24 10:43:55 -04:00
user_google_email: str,
2026-02-13 10:07:10 -05:00
to: Annotated[str, Field(description="Recipient email address.")],
subject: Annotated[str, Field(description="Email subject.")],
body: Annotated[str, Field(description="Email body content (plain text or HTML).")],
2026-02-13 10:15:31 -05:00
body_format: Annotated[
Literal["plain", "html"],
Field(
description="Email body format. Use 'plain' for plaintext or 'html' for HTML content.",
),
] = "plain",
cc: Annotated[
Optional[str], Field(description="Optional CC email address.")
] = None,
bcc: Annotated[
Optional[str], Field(description="Optional BCC email address.")
] = None,
from_name: Annotated[
Optional[str],
Field(
description="Optional sender display name (e.g., 'Peter Hartree'). If provided, the From header will be formatted as 'Name <email>'.",
),
] = None,
from_email: Annotated[
Optional[str],
Field(
description="Optional 'Send As' alias email address. Must be configured in Gmail settings (Settings > Accounts > Send mail as). If not provided, uses the authenticated user's email.",
),
] = None,
thread_id: Annotated[
Optional[str],
Field(
description="Optional Gmail thread ID to reply within.",
),
] = None,
in_reply_to: Annotated[
Optional[str],
Field(
description="Optional Message-ID of the message being replied to.",
),
] = None,
references: Annotated[
Optional[str],
Field(
description="Optional chain of Message-IDs for proper threading.",
),
] = None,
attachments: Annotated[
Optional[List[Dict[str, str]]],
Field(
description='Optional list of attachments. Each can have: "path" (file path, auto-encodes), OR "content" (standard base64, not urlsafe) + "filename". Optional "mime_type". Example: [{"path": "/path/to/file.pdf"}] or [{"filename": "doc.pdf", "content": "base64data", "mime_type": "application/pdf"}]',
),
] = None,
) -> str:
"""
Sends an email using the user's Gmail account. Supports both new emails and replies with optional attachments.
Supports Gmail's "Send As" feature to send from configured alias addresses.
Args:
to (str): Recipient email address.
subject (str): Email subject.
2025-10-05 16:50:58 -04:00
body (str): Email body content.
body_format (Literal['plain', 'html']): Email body format. Defaults to 'plain'.
attachments (Optional[List[Dict[str, str]]]): Optional list of attachments. Each dict can contain:
Option 1 - File path (auto-encodes):
- 'path' (required): File path to attach
- 'filename' (optional): Override filename
- 'mime_type' (optional): Override MIME type (auto-detected if not provided)
Option 2 - Base64 content:
2026-01-29 19:43:09 -05:00
- 'content' (required): Standard base64-encoded file content (not urlsafe)
- 'filename' (required): Name of the file
- 'mime_type' (optional): MIME type (defaults to 'application/octet-stream')
cc (Optional[str]): Optional CC email address.
bcc (Optional[str]): Optional BCC email address.
from_name (Optional[str]): Optional sender display name. If provided, the From header will be formatted as 'Name <email>'.
from_email (Optional[str]): Optional 'Send As' alias email address. The alias must be
configured in Gmail settings (Settings > Accounts > Send mail as). If not provided,
the email will be sent from the authenticated user's primary email address.
user_google_email (str): The user's Google email address. Required for authentication.
thread_id (Optional[str]): Optional Gmail thread ID to reply within. When provided, sends a reply.
in_reply_to (Optional[str]): Optional Message-ID of the message being replied to. Used for proper threading.
references (Optional[str]): Optional chain of Message-IDs for proper threading. Should include all previous Message-IDs.
Returns:
str: Confirmation message with the sent email's message ID.
2025-08-13 12:05:06 -04:00
Examples:
2026-01-30 10:43:04 -05:00
# Send a new email
send_gmail_message(to="user@example.com", subject="Hello", body="Hi there!")
# Send with a custom display name
send_gmail_message(to="user@example.com", subject="Hello", body="Hi there!", from_name="John Doe")
2025-08-13 12:05:06 -04:00
2025-10-05 16:50:58 -04:00
# Send an HTML email
send_gmail_message(
to="user@example.com",
subject="Hello",
body="<strong>Hi there!</strong>",
body_format="html"
)
# Send from a configured alias (Send As)
send_gmail_message(
to="user@example.com",
subject="Business Inquiry",
body="Hello from my business address...",
from_email="business@mydomain.com"
)
# Send an email with CC and BCC
send_gmail_message(
2025-08-13 12:05:06 -04:00
to="user@example.com",
cc="manager@example.com",
bcc="archive@example.com",
2025-08-13 12:05:06 -04:00
subject="Project Update",
body="Here's the latest update..."
)
2025-08-13 12:05:06 -04:00
# Send an email with attachments (using file path)
send_gmail_message(
to="user@example.com",
subject="Report",
body="Please see attached report.",
attachments=[{
"path": "/path/to/report.pdf"
}]
)
# Send an email with attachments (using base64 content)
send_gmail_message(
to="user@example.com",
subject="Report",
body="Please see attached report.",
attachments=[{
"filename": "report.pdf",
"content": "JVBERi0xLjQK...", # base64 encoded PDF
"mime_type": "application/pdf"
}]
)
# Send a reply
send_gmail_message(
2025-08-13 12:05:06 -04:00
to="user@example.com",
subject="Re: Meeting tomorrow",
body="Thanks for the update!",
thread_id="thread_123",
in_reply_to="<message123@gmail.com>",
references="<original@gmail.com> <message123@gmail.com>"
)
"""
logger.info(
f"[send_gmail_message] Invoked. Email: '{user_google_email}', Subject: '{subject}', Attachments: {len(attachments) if attachments else 0}"
)
# Prepare the email message
# Use from_email (Send As alias) if provided, otherwise default to authenticated user
sender_email = from_email or user_google_email
2026-03-01 12:06:23 -05:00
raw_message, thread_id_final, attached_count = _prepare_gmail_message(
subject=subject,
body=body,
to=to,
cc=cc,
bcc=bcc,
thread_id=thread_id,
in_reply_to=in_reply_to,
references=references,
2025-10-05 16:50:58 -04:00
body_format=body_format,
from_email=sender_email,
from_name=from_name,
attachments=attachments if attachments else None,
)
2025-08-13 12:05:06 -04:00
2026-03-01 12:06:23 -05:00
requested_attachment_count = len(attachments or [])
if requested_attachment_count > 0 and attached_count == 0:
raise UserInputError(
"No valid attachments were added. Verify each attachment path/content and retry."
)
2025-06-18 17:31:57 -04:00
send_body = {"raw": raw_message}
2025-08-13 12:05:06 -04:00
# Associate with thread if provided
if thread_id_final:
send_body["threadId"] = thread_id_final
2025-06-18 17:31:57 -04:00
# Send the message
sent_message = await asyncio.to_thread(
service.users().messages().send(userId="me", body=send_body).execute
)
message_id = sent_message.get("id")
2026-03-01 12:06:23 -05:00
if requested_attachment_count > 0:
attachment_info = _format_attachment_result(
attached_count, requested_attachment_count
)
return f"Email sent{attachment_info}! Message ID: {message_id}"
2025-06-18 17:31:57 -04:00
return f"Email sent! Message ID: {message_id}"
2025-05-22 14:21:52 -04:00
@server.tool()
@handle_http_errors("draft_gmail_message", service_type="gmail")
@require_google_service("gmail", GMAIL_COMPOSE_SCOPE)
async def draft_gmail_message(
service,
2025-05-24 10:43:55 -04:00
user_google_email: str,
2026-02-13 10:07:10 -05:00
subject: Annotated[str, Field(description="Email subject.")],
body: Annotated[str, Field(description="Email body (plain text).")],
2026-02-13 10:15:31 -05:00
body_format: Annotated[
Literal["plain", "html"],
Field(
description="Email body format. Use 'plain' for plaintext or 'html' for HTML content.",
),
] = "plain",
to: Annotated[
Optional[str],
Field(
description="Optional recipient email address.",
),
] = None,
cc: Annotated[
Optional[str], Field(description="Optional CC email address.")
] = None,
bcc: Annotated[
Optional[str], Field(description="Optional BCC email address.")
] = None,
from_name: Annotated[
Optional[str],
Field(
description="Optional sender display name (e.g., 'Peter Hartree'). If provided, the From header will be formatted as 'Name <email>'.",
),
] = None,
from_email: Annotated[
Optional[str],
Field(
description="Optional 'Send As' alias email address. Must be configured in Gmail settings (Settings > Accounts > Send mail as). If not provided, uses the authenticated user's email.",
),
] = None,
thread_id: Annotated[
Optional[str],
Field(
description="Optional Gmail thread ID to reply within.",
),
] = None,
in_reply_to: Annotated[
Optional[str],
Field(
description="Optional Message-ID of the message being replied to.",
),
] = None,
references: Annotated[
Optional[str],
Field(
description="Optional chain of Message-IDs for proper threading.",
),
] = None,
attachments: Annotated[
Optional[List[Dict[str, str]]],
Field(
description="Optional list of attachments. Each can have: 'path' (file path, auto-encodes), OR 'content' (standard base64, not urlsafe) + 'filename'. Optional 'mime_type' (auto-detected from path if not provided).",
),
] = None,
2026-03-01 12:06:23 -05:00
include_signature: Annotated[
bool,
Field(
description="Whether to append the Gmail signature from Settings > Signature when available. Defaults to true.",
),
] = True,
quote_original: Annotated[
bool,
Field(
description="Whether to include the original message as a quoted reply. Requires thread_id. Defaults to false.",
),
] = False,
) -> str:
"""
Creates a draft email in the user's Gmail account. Supports both new drafts and reply drafts with optional attachments.
Supports Gmail's "Send As" feature to draft from configured alias addresses.
Args:
user_google_email (str): The user's Google email address. Required for authentication.
subject (str): Email subject.
body (str): Email body (plain text).
body_format (Literal['plain', 'html']): Email body format. Defaults to 'plain'.
to (Optional[str]): Optional recipient email address. Can be left empty for drafts.
cc (Optional[str]): Optional CC email address.
bcc (Optional[str]): Optional BCC email address.
from_name (Optional[str]): Optional sender display name. If provided, the From header will be formatted as 'Name <email>'.
from_email (Optional[str]): Optional 'Send As' alias email address. The alias must be
configured in Gmail settings (Settings > Accounts > Send mail as). If not provided,
the draft will be from the authenticated user's primary email address.
thread_id (Optional[str]): Optional Gmail thread ID to reply within. When provided, creates a reply draft.
in_reply_to (Optional[str]): Optional Message-ID of the message being replied to. Used for proper threading.
references (Optional[str]): Optional chain of Message-IDs for proper threading. Should include all previous Message-IDs.
attachments (List[Dict[str, str]]): Optional list of attachments. Each dict can contain:
Option 1 - File path (auto-encodes):
- 'path' (required): File path to attach
- 'filename' (optional): Override filename
- 'mime_type' (optional): Override MIME type (auto-detected if not provided)
Option 2 - Base64 content:
2026-01-29 19:43:09 -05:00
- 'content' (required): Standard base64-encoded file content (not urlsafe)
- 'filename' (required): Name of the file
- 'mime_type' (optional): MIME type (defaults to 'application/octet-stream')
2026-03-01 12:06:23 -05:00
include_signature (bool): Whether to append Gmail signature HTML from send-as settings.
If unavailable (e.g., missing gmail.settings.basic scope), the draft is still created without signature.
quote_original (bool): Whether to include the original message as a quoted reply.
Requires thread_id to be provided. When enabled, fetches the original message
and appends it below the signature. Defaults to False.
Returns:
str: Confirmation message with the created draft's ID.
2025-08-13 12:05:06 -04:00
Examples:
# Create a new draft
draft_gmail_message(subject="Hello", body="Hi there!", to="user@example.com")
2025-08-13 12:05:06 -04:00
# Create a draft from a configured alias (Send As)
draft_gmail_message(
subject="Business Inquiry",
body="Hello from my business address...",
to="user@example.com",
from_email="business@mydomain.com"
)
# Create a plaintext draft with CC and BCC
draft_gmail_message(
2025-08-13 12:05:06 -04:00
subject="Project Update",
body="Here's the latest update...",
to="user@example.com",
cc="manager@example.com",
bcc="archive@example.com"
)
2025-08-13 12:05:06 -04:00
# Create a HTML draft with CC and BCC
draft_gmail_message(
subject="Project Update",
body="<strong>Hi there!</strong>",
body_format="html",
to="user@example.com",
cc="manager@example.com",
bcc="archive@example.com"
)
# Create a reply draft in plaintext
draft_gmail_message(
2025-08-13 12:05:06 -04:00
subject="Re: Meeting tomorrow",
body="Thanks for the update!",
to="user@example.com",
thread_id="thread_123",
in_reply_to="<message123@gmail.com>",
references="<original@gmail.com> <message123@gmail.com>"
)
# Create a reply draft in HTML
draft_gmail_message(
subject="Re: Meeting tomorrow",
body="<strong>Thanks for the update!</strong>",
body_format="html",
to="user@example.com",
thread_id="thread_123",
in_reply_to="<message123@gmail.com>",
references="<original@gmail.com> <message123@gmail.com>"
)
"""
2025-05-22 14:21:52 -04:00
logger.info(
f"[draft_gmail_message] Invoked. Email: '{user_google_email}', Subject: '{subject}'"
2025-05-22 14:21:52 -04:00
)
# Prepare the email message
# Use from_email (Send As alias) if provided, otherwise default to authenticated user
sender_email = from_email or user_google_email
2026-03-01 12:06:23 -05:00
draft_body = body
signature_html = ""
2026-03-01 12:06:23 -05:00
if include_signature:
signature_html = await _get_send_as_signature_html(
service, from_email=sender_email
)
if quote_original and thread_id:
original = await _fetch_original_for_quote(service, thread_id, in_reply_to)
if original:
draft_body = _build_quoted_reply_body(
draft_body, body_format, signature_html, original
)
else:
draft_body = _append_signature_to_body(draft_body, body_format, signature_html)
else:
2026-03-01 12:06:23 -05:00
draft_body = _append_signature_to_body(draft_body, body_format, signature_html)
raw_message, thread_id_final, attached_count = _prepare_gmail_message(
subject=subject,
2026-03-01 12:06:23 -05:00
body=draft_body,
body_format=body_format,
to=to,
cc=cc,
bcc=bcc,
thread_id=thread_id,
in_reply_to=in_reply_to,
references=references,
from_email=sender_email,
from_name=from_name,
attachments=attachments,
)
2026-03-01 12:06:23 -05:00
requested_attachment_count = len(attachments or [])
if requested_attachment_count > 0 and attached_count == 0:
raise UserInputError(
"No valid attachments were added. Verify each attachment path/content and retry."
)
2025-06-18 17:31:57 -04:00
# Create a draft instead of sending
draft_body = {"message": {"raw": raw_message}}
2025-08-13 12:05:06 -04:00
# Associate with thread if provided
if thread_id_final:
draft_body["message"]["threadId"] = thread_id_final
2025-06-18 17:31:57 -04:00
# Create the draft
created_draft = await asyncio.to_thread(
service.users().drafts().create(userId="me", body=draft_body).execute
)
draft_id = created_draft.get("id")
2026-03-01 12:06:23 -05:00
attachment_info = _format_attachment_result(
attached_count, requested_attachment_count
)
return f"Draft created{attachment_info}! Draft ID: {draft_id}"
2025-07-17 14:46:25 -04:00
def _format_thread_content(thread_data: dict, thread_id: str) -> str:
"""
2025-07-17 14:46:25 -04:00
Helper function to format thread content from Gmail API response.
Args:
2025-07-17 14:46:25 -04:00
thread_data (dict): Thread data from Gmail API
thread_id (str): Thread ID for display
Returns:
2025-07-17 14:46:25 -04:00
str: Formatted thread content
"""
2025-07-17 14:46:25 -04:00
messages = thread_data.get("messages", [])
2025-06-18 17:31:57 -04:00
if not messages:
return f"No messages found in thread '{thread_id}'."
# Extract thread subject from the first message
first_message = messages[0]
first_headers = {
h["name"]: h["value"]
for h in first_message.get("payload", {}).get("headers", [])
}
thread_subject = first_headers.get("Subject", "(no subject)")
# Build the thread content
content_lines = [
f"Thread ID: {thread_id}",
f"Subject: {thread_subject}",
f"Messages: {len(messages)}",
"",
]
2025-06-18 17:31:57 -04:00
# Process each message in the thread
for i, message in enumerate(messages, 1):
# Extract headers
headers = {
2025-07-17 14:46:25 -04:00
h["name"]: h["value"] for h in message.get("payload", {}).get("headers", [])
}
2025-06-18 17:31:57 -04:00
sender = headers.get("From", "(unknown sender)")
date = headers.get("Date", "(unknown date)")
subject = headers.get("Subject", "(no subject)")
rfc822_message_id = headers.get("Message-ID", "")
in_reply_to = headers.get("In-Reply-To", "")
references = headers.get("References", "")
# Extract both text and HTML bodies
2025-06-18 17:31:57 -04:00
payload = message.get("payload", {})
bodies = _extract_message_bodies(payload)
text_body = bodies.get("text", "")
html_body = bodies.get("html", "")
2025-08-13 12:05:06 -04:00
# Format body content with HTML fallback
2025-08-13 12:04:07 -04:00
body_data = _format_body_content(text_body, html_body)
2025-06-18 17:31:57 -04:00
# Add message to content
content_lines.extend(
[
f"=== Message {i} ===",
f"From: {sender}",
f"Date: {date}",
]
)
if rfc822_message_id:
content_lines.append(f"Message-ID: {rfc822_message_id}")
if in_reply_to:
content_lines.append(f"In-Reply-To: {in_reply_to}")
if references:
content_lines.append(f"References: {references}")
2025-06-18 17:31:57 -04:00
# Only show subject if it's different from thread subject
if subject != thread_subject:
content_lines.append(f"Subject: {subject}")
2025-06-18 17:31:57 -04:00
content_lines.extend(
[
"",
body_data,
2025-06-18 17:31:57 -04:00
"",
]
2025-05-22 14:21:52 -04:00
)
2025-06-18 17:31:57 -04:00
2025-07-17 14:46:25 -04:00
return "\n".join(content_lines)
@server.tool()
@require_google_service("gmail", "gmail_read")
@handle_http_errors("get_gmail_thread_content", is_read_only=True, service_type="gmail")
2025-07-17 14:46:25 -04:00
async def get_gmail_thread_content(
service, thread_id: str, user_google_email: str
) -> str:
"""
Retrieves the complete content of a Gmail conversation thread, including all messages.
Args:
thread_id (str): The unique ID of the Gmail thread to retrieve.
user_google_email (str): The user's Google email address. Required.
Returns:
str: The complete thread content with all messages formatted for reading.
"""
logger.info(
f"[get_gmail_thread_content] Invoked. Thread ID: '{thread_id}', Email: '{user_google_email}'"
)
# Fetch the complete thread with all messages
thread_response = await asyncio.to_thread(
service.users().threads().get(userId="me", id=thread_id, format="full").execute
)
return _format_thread_content(thread_response, thread_id)
@server.tool()
@require_google_service("gmail", "gmail_read")
2025-12-13 13:49:28 -08:00
@handle_http_errors(
"get_gmail_threads_content_batch", is_read_only=True, service_type="gmail"
)
async def get_gmail_threads_content_batch(
service,
thread_ids: List[str],
user_google_email: str,
) -> str:
"""
Retrieves the content of multiple Gmail threads in a single batch request.
Supports up to 25 threads per batch to prevent SSL connection exhaustion.
Args:
2025-07-23 14:01:02 -04:00
thread_ids (List[str]): A list of Gmail thread IDs to retrieve. The function will automatically batch requests in chunks of 25.
user_google_email (str): The user's Google email address. Required.
Returns:
str: A formatted list of thread contents with separators.
"""
logger.info(
f"[get_gmail_threads_content_batch] Invoked. Thread count: {len(thread_ids)}, Email: '{user_google_email}'"
)
if not thread_ids:
2025-07-17 14:46:25 -04:00
raise ValueError("No thread IDs provided")
output_threads = []
2025-07-17 14:46:25 -04:00
def _batch_callback(request_id, response, exception):
"""Callback for batch requests"""
results[request_id] = {"data": response, "error": exception}
# Process in smaller chunks to prevent SSL connection exhaustion
2025-07-23 14:01:02 -04:00
for chunk_start in range(0, len(thread_ids), GMAIL_BATCH_SIZE):
chunk_ids = thread_ids[chunk_start : chunk_start + GMAIL_BATCH_SIZE]
results: Dict[str, Dict] = {}
# Try to use batch API
try:
batch = service.new_batch_http_request(callback=_batch_callback)
for tid in chunk_ids:
2025-07-17 14:46:25 -04:00
req = service.users().threads().get(userId="me", id=tid, format="full")
batch.add(req, request_id=tid)
# Execute batch request
await asyncio.to_thread(batch.execute)
except Exception as batch_error:
# Fallback to sequential processing instead of parallel to prevent SSL exhaustion
logger.warning(
f"[get_gmail_threads_content_batch] Batch API failed, falling back to sequential processing: {batch_error}"
)
async def fetch_thread_with_retry(tid: str, max_retries: int = 3):
"""Fetch a single thread with exponential backoff retry for SSL errors"""
for attempt in range(max_retries):
try:
thread = await asyncio.to_thread(
service.users()
.threads()
.get(userId="me", id=tid, format="full")
.execute
)
return tid, thread, None
except ssl.SSLError as ssl_error:
if attempt < max_retries - 1:
# Exponential backoff: 1s, 2s, 4s
2025-12-13 13:49:28 -08:00
delay = 2**attempt
logger.warning(
f"[get_gmail_threads_content_batch] SSL error for thread {tid} on attempt {attempt + 1}: {ssl_error}. Retrying in {delay}s..."
)
await asyncio.sleep(delay)
else:
logger.error(
f"[get_gmail_threads_content_batch] SSL error for thread {tid} on final attempt: {ssl_error}"
)
return tid, None, ssl_error
except Exception as e:
return tid, None, e
# Process threads sequentially with small delays to prevent connection exhaustion
for tid in chunk_ids:
tid_result, thread_data, error = await fetch_thread_with_retry(tid)
results[tid_result] = {"data": thread_data, "error": error}
# Brief delay between requests to allow connection cleanup
2025-07-23 14:01:02 -04:00
await asyncio.sleep(GMAIL_REQUEST_DELAY)
# Process results for this chunk
for tid in chunk_ids:
entry = results.get(tid, {"data": None, "error": "No result"})
if entry["error"]:
2025-07-17 14:46:25 -04:00
output_threads.append(f"⚠️ Thread {tid}: {entry['error']}\n")
else:
thread = entry["data"]
if not thread:
2025-07-17 14:46:25 -04:00
output_threads.append(f"⚠️ Thread {tid}: No data returned\n")
continue
2025-07-17 14:46:25 -04:00
output_threads.append(_format_thread_content(thread, tid))
# Combine all threads with separators
2025-07-17 14:46:25 -04:00
header = f"Retrieved {len(thread_ids)} threads:"
return header + "\n\n" + "\n---\n\n".join(output_threads)
@server.tool()
@handle_http_errors("list_gmail_labels", is_read_only=True, service_type="gmail")
@require_google_service("gmail", "gmail_read")
async def list_gmail_labels(service, user_google_email: str) -> str:
"""
Lists all labels in the user's Gmail account.
Args:
user_google_email (str): The user's Google email address. Required.
Returns:
str: A formatted list of all labels with their IDs, names, and types.
"""
logger.info(f"[list_gmail_labels] Invoked. Email: '{user_google_email}'")
2025-06-18 17:31:57 -04:00
response = await asyncio.to_thread(
service.users().labels().list(userId="me").execute
)
labels = response.get("labels", [])
2025-06-18 17:31:57 -04:00
if not labels:
return "No labels found."
2025-06-06 12:50:32 -04:00
2025-06-18 17:31:57 -04:00
lines = [f"Found {len(labels)} labels:", ""]
2025-06-06 12:50:32 -04:00
2025-06-18 17:31:57 -04:00
system_labels = []
user_labels = []
2025-06-18 17:31:57 -04:00
for label in labels:
if label.get("type") == "system":
system_labels.append(label)
else:
user_labels.append(label)
2025-06-18 17:31:57 -04:00
if system_labels:
lines.append("📂 SYSTEM LABELS:")
for label in system_labels:
lines.append(f"{label['name']} (ID: {label['id']})")
lines.append("")
2025-06-18 17:31:57 -04:00
if user_labels:
lines.append("🏷️ USER LABELS:")
for label in user_labels:
lines.append(f"{label['name']} (ID: {label['id']})")
2025-06-18 17:31:57 -04:00
return "\n".join(lines)
@server.tool()
@handle_http_errors("manage_gmail_label", service_type="gmail")
@require_google_service("gmail", GMAIL_LABELS_SCOPE)
async def manage_gmail_label(
service,
user_google_email: str,
action: Literal["create", "update", "delete"],
name: Optional[str] = None,
label_id: Optional[str] = None,
label_list_visibility: Literal["labelShow", "labelHide"] = "labelShow",
message_list_visibility: Literal["show", "hide"] = "show",
) -> str:
"""
Manages Gmail labels: create, update, or delete labels.
Args:
user_google_email (str): The user's Google email address. Required.
action (Literal["create", "update", "delete"]): Action to perform on the label.
name (Optional[str]): Label name. Required for create, optional for update.
label_id (Optional[str]): Label ID. Required for update and delete operations.
label_list_visibility (Literal["labelShow", "labelHide"]): Whether the label is shown in the label list.
message_list_visibility (Literal["show", "hide"]): Whether the label is shown in the message list.
Returns:
str: Confirmation message of the label operation.
"""
2025-07-17 14:46:25 -04:00
logger.info(
f"[manage_gmail_label] Invoked. Email: '{user_google_email}', Action: '{action}'"
)
if action == "create" and not name:
raise Exception("Label name is required for create action.")
2025-06-06 12:50:32 -04:00
if action in ["update", "delete"] and not label_id:
raise Exception("Label ID is required for update and delete actions.")
2025-06-18 17:31:57 -04:00
if action == "create":
label_object = {
"name": name,
"labelListVisibility": label_list_visibility,
"messageListVisibility": message_list_visibility,
}
created_label = await asyncio.to_thread(
service.users().labels().create(userId="me", body=label_object).execute
)
return f"Label created successfully!\nName: {created_label['name']}\nID: {created_label['id']}"
2025-06-06 12:50:32 -04:00
2025-06-18 17:31:57 -04:00
elif action == "update":
current_label = await asyncio.to_thread(
service.users().labels().get(userId="me", id=label_id).execute
)
2025-06-06 12:50:32 -04:00
2025-06-18 17:31:57 -04:00
label_object = {
"id": label_id,
"name": name if name is not None else current_label["name"],
"labelListVisibility": label_list_visibility,
"messageListVisibility": message_list_visibility,
}
2025-06-18 17:31:57 -04:00
updated_label = await asyncio.to_thread(
2025-07-17 14:46:25 -04:00
service.users()
.labels()
.update(userId="me", id=label_id, body=label_object)
.execute
2025-06-18 17:31:57 -04:00
)
return f"Label updated successfully!\nName: {updated_label['name']}\nID: {updated_label['id']}"
2025-06-06 12:50:32 -04:00
2025-06-18 17:31:57 -04:00
elif action == "delete":
label = await asyncio.to_thread(
service.users().labels().get(userId="me", id=label_id).execute
)
label_name = label["name"]
2025-06-18 17:31:57 -04:00
await asyncio.to_thread(
service.users().labels().delete(userId="me", id=label_id).execute
)
return f"Label '{label_name}' (ID: {label_id}) deleted successfully!"
2025-12-13 13:40:05 -08:00
@server.tool()
@handle_http_errors("list_gmail_filters", is_read_only=True, service_type="gmail")
@require_google_service("gmail", "gmail_settings_basic")
async def list_gmail_filters(service, user_google_email: str) -> str:
"""
Lists all Gmail filters configured in the user's mailbox.
Args:
user_google_email (str): The user's Google email address. Required.
Returns:
str: A formatted list of filters with their criteria and actions.
"""
logger.info(f"[list_gmail_filters] Invoked. Email: '{user_google_email}'")
response = await asyncio.to_thread(
service.users().settings().filters().list(userId="me").execute
)
filters = response.get("filter") or response.get("filters") or []
if not filters:
return "No filters found."
lines = [f"Found {len(filters)} filters:", ""]
for filter_obj in filters:
filter_id = filter_obj.get("id", "(no id)")
criteria = filter_obj.get("criteria", {})
action = filter_obj.get("action", {})
lines.append(f"🔹 Filter ID: {filter_id}")
lines.append(" Criteria:")
criteria_lines = []
if criteria.get("from"):
criteria_lines.append(f"From: {criteria['from']}")
if criteria.get("to"):
criteria_lines.append(f"To: {criteria['to']}")
if criteria.get("subject"):
criteria_lines.append(f"Subject: {criteria['subject']}")
if criteria.get("query"):
criteria_lines.append(f"Query: {criteria['query']}")
if criteria.get("negatedQuery"):
criteria_lines.append(f"Exclude Query: {criteria['negatedQuery']}")
if criteria.get("hasAttachment"):
criteria_lines.append("Has attachment")
if criteria.get("excludeChats"):
criteria_lines.append("Exclude chats")
if criteria.get("size"):
comparison = criteria.get("sizeComparison", "")
criteria_lines.append(
f"Size {comparison or ''} {criteria['size']} bytes".strip()
)
if not criteria_lines:
criteria_lines.append("(none)")
lines.extend([f"{line}" for line in criteria_lines])
lines.append(" Actions:")
action_lines = []
if action.get("forward"):
action_lines.append(f"Forward to: {action['forward']}")
if action.get("removeLabelIds"):
2025-12-13 13:49:28 -08:00
action_lines.append(f"Remove labels: {', '.join(action['removeLabelIds'])}")
2025-12-13 13:40:05 -08:00
if action.get("addLabelIds"):
action_lines.append(f"Add labels: {', '.join(action['addLabelIds'])}")
if not action_lines:
action_lines.append("(none)")
lines.extend([f"{line}" for line in action_lines])
lines.append("")
return "\n".join(lines).rstrip()
@server.tool()
@handle_http_errors("manage_gmail_filter", service_type="gmail")
2025-12-13 13:47:41 -08:00
@require_google_service("gmail", "gmail_settings_basic")
async def manage_gmail_filter(
2025-12-13 13:40:05 -08:00
service,
user_google_email: str,
action: str,
criteria: Optional[Dict[str, Any]] = None,
filter_action: Optional[Dict[str, Any]] = None,
filter_id: Optional[str] = None,
2025-12-13 13:40:05 -08:00
) -> str:
"""
Manages Gmail filters. Supports creating and deleting filters.
2025-12-13 13:40:05 -08:00
Args:
user_google_email (str): The user's Google email address. Required.
action (str): Action to perform - "create" or "delete".
criteria (Optional[Dict[str, Any]]): Filter criteria object (required for create).
filter_action (Optional[Dict[str, Any]]): Filter action object (required for create). Named 'filter_action' to avoid shadowing the 'action' parameter.
filter_id (Optional[str]): ID of the filter to delete (required for delete).
2025-12-13 13:40:05 -08:00
Returns:
str: Confirmation message with filter details.
2025-12-13 13:40:05 -08:00
"""
action_lower = action.lower().strip()
if action_lower == "create":
if not criteria or not filter_action:
2026-03-01 17:54:47 +00:00
raise ValueError(
"criteria and filter_action are required for create action"
)
logger.info("[manage_gmail_filter] Creating filter")
filter_body = {"criteria": criteria, "action": filter_action}
created_filter = await asyncio.to_thread(
service.users()
.settings()
.filters()
.create(userId="me", body=filter_body)
.execute
)
fid = created_filter.get("id", "(unknown)")
return f"Filter created successfully!\nFilter ID: {fid}"
elif action_lower == "delete":
if not filter_id:
raise ValueError("filter_id is required for delete action")
logger.info(f"[manage_gmail_filter] Deleting filter {filter_id}")
filter_details = await asyncio.to_thread(
service.users().settings().filters().get(userId="me", id=filter_id).execute
)
await asyncio.to_thread(
2026-03-01 17:54:47 +00:00
service.users()
.settings()
.filters()
.delete(userId="me", id=filter_id)
.execute
)
criteria_info = filter_details.get("criteria", {})
action_info = filter_details.get("action", {})
return (
"Filter deleted successfully!\n"
f"Filter ID: {filter_id}\n"
f"Criteria: {criteria_info or '(none)'}\n"
f"Action: {action_info or '(none)'}"
)
else:
2026-03-01 17:54:47 +00:00
raise ValueError(
f"Invalid action '{action_lower}'. Must be 'create' or 'delete'."
)
2025-12-13 13:40:05 -08:00
@server.tool()
@handle_http_errors("modify_gmail_message_labels", service_type="gmail")
@require_google_service("gmail", GMAIL_MODIFY_SCOPE)
async def modify_gmail_message_labels(
service,
user_google_email: str,
message_id: str,
2026-02-11 19:03:24 -05:00
add_label_ids: Optional[List[str]] = None,
remove_label_ids: Optional[List[str]] = None,
) -> str:
"""
Adds or removes labels from a Gmail message.
2025-07-31 10:00:49 -04:00
To archive an email, remove the INBOX label.
To delete an email, add the TRASH label.
Args:
user_google_email (str): The user's Google email address. Required.
message_id (str): The ID of the message to modify.
add_label_ids (Optional[List[str]]): List of label IDs to add to the message.
remove_label_ids (Optional[List[str]]): List of label IDs to remove from the message.
Returns:
str: Confirmation message of the label changes applied to the message.
"""
2025-07-17 14:46:25 -04:00
logger.info(
f"[modify_gmail_message_labels] Invoked. Email: '{user_google_email}', Message ID: '{message_id}'"
)
if not add_label_ids and not remove_label_ids:
2025-07-17 14:46:25 -04:00
raise Exception(
"At least one of add_label_ids or remove_label_ids must be provided."
)
2025-06-18 17:31:57 -04:00
body = {}
if add_label_ids:
body["addLabelIds"] = add_label_ids
if remove_label_ids:
body["removeLabelIds"] = remove_label_ids
2025-06-18 17:31:57 -04:00
await asyncio.to_thread(
service.users().messages().modify(userId="me", id=message_id, body=body).execute
)
2025-06-18 17:31:57 -04:00
actions = []
if add_label_ids:
actions.append(f"Added labels: {', '.join(add_label_ids)}")
if remove_label_ids:
actions.append(f"Removed labels: {', '.join(remove_label_ids)}")
2025-06-18 17:31:57 -04:00
return f"Message labels updated successfully!\nMessage ID: {message_id}\n{'; '.join(actions)}"
@server.tool()
@handle_http_errors("batch_modify_gmail_message_labels", service_type="gmail")
@require_google_service("gmail", GMAIL_MODIFY_SCOPE)
async def batch_modify_gmail_message_labels(
service,
user_google_email: str,
message_ids: List[str],
2026-02-11 19:03:24 -05:00
add_label_ids: Optional[List[str]] = None,
remove_label_ids: Optional[List[str]] = None,
) -> str:
"""
Adds or removes labels from multiple Gmail messages in a single batch request.
Args:
user_google_email (str): The user's Google email address. Required.
message_ids (List[str]): A list of message IDs to modify.
add_label_ids (Optional[List[str]]): List of label IDs to add to the messages.
remove_label_ids (Optional[List[str]]): List of label IDs to remove from the messages.
Returns:
str: Confirmation message of the label changes applied to the messages.
"""
logger.info(
f"[batch_modify_gmail_message_labels] Invoked. Email: '{user_google_email}', Message IDs: '{message_ids}'"
)
if not add_label_ids and not remove_label_ids:
raise Exception(
"At least one of add_label_ids or remove_label_ids must be provided."
)
body = {"ids": message_ids}
if add_label_ids:
body["addLabelIds"] = add_label_ids
if remove_label_ids:
body["removeLabelIds"] = remove_label_ids
await asyncio.to_thread(
service.users().messages().batchModify(userId="me", body=body).execute
)
actions = []
if add_label_ids:
actions.append(f"Added labels: {', '.join(add_label_ids)}")
if remove_label_ids:
actions.append(f"Removed labels: {', '.join(remove_label_ids)}")
return f"Labels updated for {len(message_ids)} messages: {'; '.join(actions)}"