workspace-mcp 0.2.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- auth/__init__.py +1 -0
- auth/google_auth.py +549 -0
- auth/oauth_callback_server.py +241 -0
- auth/oauth_responses.py +223 -0
- auth/scopes.py +108 -0
- auth/service_decorator.py +404 -0
- core/__init__.py +1 -0
- core/server.py +214 -0
- core/utils.py +162 -0
- gcalendar/__init__.py +1 -0
- gcalendar/calendar_tools.py +496 -0
- gchat/__init__.py +6 -0
- gchat/chat_tools.py +254 -0
- gdocs/__init__.py +0 -0
- gdocs/docs_tools.py +244 -0
- gdrive/__init__.py +0 -0
- gdrive/drive_tools.py +362 -0
- gforms/__init__.py +3 -0
- gforms/forms_tools.py +318 -0
- gmail/__init__.py +1 -0
- gmail/gmail_tools.py +807 -0
- gsheets/__init__.py +23 -0
- gsheets/sheets_tools.py +393 -0
- gslides/__init__.py +0 -0
- gslides/slides_tools.py +316 -0
- main.py +160 -0
- workspace_mcp-0.2.0.dist-info/METADATA +29 -0
- workspace_mcp-0.2.0.dist-info/RECORD +32 -0
- workspace_mcp-0.2.0.dist-info/WHEEL +5 -0
- workspace_mcp-0.2.0.dist-info/entry_points.txt +2 -0
- workspace_mcp-0.2.0.dist-info/licenses/LICENSE +21 -0
- workspace_mcp-0.2.0.dist-info/top_level.txt +11 -0
gmail/gmail_tools.py
ADDED
@@ -0,0 +1,807 @@
|
|
1
|
+
"""
|
2
|
+
Google Gmail MCP Tools
|
3
|
+
|
4
|
+
This module provides MCP tools for interacting with the Gmail API.
|
5
|
+
"""
|
6
|
+
|
7
|
+
import logging
|
8
|
+
import asyncio
|
9
|
+
import base64
|
10
|
+
from typing import Optional, List, Dict, Literal
|
11
|
+
|
12
|
+
from email.mime.text import MIMEText
|
13
|
+
|
14
|
+
from mcp import types
|
15
|
+
from fastapi import Body
|
16
|
+
from googleapiclient.errors import HttpError
|
17
|
+
|
18
|
+
from auth.service_decorator import require_google_service
|
19
|
+
|
20
|
+
from core.server import (
|
21
|
+
GMAIL_READONLY_SCOPE,
|
22
|
+
GMAIL_SEND_SCOPE,
|
23
|
+
GMAIL_COMPOSE_SCOPE,
|
24
|
+
GMAIL_MODIFY_SCOPE,
|
25
|
+
GMAIL_LABELS_SCOPE,
|
26
|
+
server,
|
27
|
+
)
|
28
|
+
|
29
|
+
logger = logging.getLogger(__name__)
|
30
|
+
|
31
|
+
|
32
|
+
def _extract_message_body(payload):
|
33
|
+
"""
|
34
|
+
Helper function to extract plain text body from a Gmail message payload.
|
35
|
+
|
36
|
+
Args:
|
37
|
+
payload (dict): The message payload from Gmail API
|
38
|
+
|
39
|
+
Returns:
|
40
|
+
str: The plain text body content, or empty string if not found
|
41
|
+
"""
|
42
|
+
body_data = ""
|
43
|
+
parts = [payload] if "parts" not in payload else payload.get("parts", [])
|
44
|
+
|
45
|
+
part_queue = list(parts) # Use a queue for BFS traversal of parts
|
46
|
+
while part_queue:
|
47
|
+
part = part_queue.pop(0)
|
48
|
+
if part.get("mimeType") == "text/plain" and part.get("body", {}).get("data"):
|
49
|
+
data = base64.urlsafe_b64decode(part["body"]["data"])
|
50
|
+
body_data = data.decode("utf-8", errors="ignore")
|
51
|
+
break # Found plain text body
|
52
|
+
elif part.get("mimeType", "").startswith("multipart/") and "parts" in part:
|
53
|
+
part_queue.extend(part.get("parts", [])) # Add sub-parts to the queue
|
54
|
+
|
55
|
+
# If no plain text found, check the main payload body if it exists
|
56
|
+
if (
|
57
|
+
not body_data
|
58
|
+
and payload.get("mimeType") == "text/plain"
|
59
|
+
and payload.get("body", {}).get("data")
|
60
|
+
):
|
61
|
+
data = base64.urlsafe_b64decode(payload["body"]["data"])
|
62
|
+
body_data = data.decode("utf-8", errors="ignore")
|
63
|
+
|
64
|
+
return body_data
|
65
|
+
|
66
|
+
|
67
|
+
def _extract_headers(payload: dict, header_names: List[str]) -> Dict[str, str]:
|
68
|
+
"""
|
69
|
+
Extract specified headers from a Gmail message payload.
|
70
|
+
|
71
|
+
Args:
|
72
|
+
payload: The message payload from Gmail API
|
73
|
+
header_names: List of header names to extract
|
74
|
+
|
75
|
+
Returns:
|
76
|
+
Dict mapping header names to their values
|
77
|
+
"""
|
78
|
+
headers = {}
|
79
|
+
for header in payload.get("headers", []):
|
80
|
+
if header["name"] in header_names:
|
81
|
+
headers[header["name"]] = header["value"]
|
82
|
+
return headers
|
83
|
+
|
84
|
+
|
85
|
+
def _generate_gmail_web_url(item_id: str, account_index: int = 0) -> str:
|
86
|
+
"""
|
87
|
+
Generate Gmail web interface URL for a message or thread ID.
|
88
|
+
Uses #all to access messages from any Gmail folder/label (not just inbox).
|
89
|
+
|
90
|
+
Args:
|
91
|
+
item_id: Gmail message ID or thread ID
|
92
|
+
account_index: Google account index (default 0 for primary account)
|
93
|
+
|
94
|
+
Returns:
|
95
|
+
Gmail web interface URL that opens the message/thread in Gmail web interface
|
96
|
+
"""
|
97
|
+
return f"https://mail.google.com/mail/u/{account_index}/#all/{item_id}"
|
98
|
+
|
99
|
+
|
100
|
+
def _format_gmail_results_plain(messages: list, query: str) -> str:
|
101
|
+
"""Format Gmail search results in clean, LLM-friendly plain text."""
|
102
|
+
if not messages:
|
103
|
+
return f"No messages found for query: '{query}'"
|
104
|
+
|
105
|
+
lines = [
|
106
|
+
f"Found {len(messages)} messages matching '{query}':",
|
107
|
+
"",
|
108
|
+
"📧 MESSAGES:",
|
109
|
+
]
|
110
|
+
|
111
|
+
for i, msg in enumerate(messages, 1):
|
112
|
+
message_url = _generate_gmail_web_url(msg["id"])
|
113
|
+
thread_url = _generate_gmail_web_url(msg["threadId"])
|
114
|
+
|
115
|
+
lines.extend([
|
116
|
+
f" {i}. Message ID: {msg['id']}",
|
117
|
+
f" Web Link: {message_url}",
|
118
|
+
f" Thread ID: {msg['threadId']}",
|
119
|
+
f" Thread Link: {thread_url}",
|
120
|
+
""
|
121
|
+
])
|
122
|
+
|
123
|
+
lines.extend([
|
124
|
+
"💡 USAGE:",
|
125
|
+
" • Pass the Message IDs **as a list** to get_gmail_messages_content_batch()",
|
126
|
+
" e.g. get_gmail_messages_content_batch(message_ids=[...])",
|
127
|
+
" • Pass the Thread IDs to get_gmail_thread_content() (single) _or_",
|
128
|
+
" get_gmail_threads_content_batch() (coming soon)"
|
129
|
+
])
|
130
|
+
|
131
|
+
return "\n".join(lines)
|
132
|
+
|
133
|
+
|
134
|
+
@server.tool()
|
135
|
+
@require_google_service("gmail", "gmail_read")
|
136
|
+
async def search_gmail_messages(
|
137
|
+
service, query: str, user_google_email: str, page_size: int = 10
|
138
|
+
) -> str:
|
139
|
+
"""
|
140
|
+
Searches messages in a user's Gmail account based on a query.
|
141
|
+
Returns both Message IDs and Thread IDs for each found message, along with Gmail web interface links for manual verification.
|
142
|
+
|
143
|
+
Args:
|
144
|
+
query (str): The search query. Supports standard Gmail search operators.
|
145
|
+
user_google_email (str): The user's Google email address. Required.
|
146
|
+
page_size (int): The maximum number of messages to return. Defaults to 10.
|
147
|
+
|
148
|
+
Returns:
|
149
|
+
str: LLM-friendly structured results with Message IDs, Thread IDs, and clickable Gmail web interface URLs for each found message.
|
150
|
+
"""
|
151
|
+
logger.info(f"[search_gmail_messages] Email: '{user_google_email}', Query: '{query}'")
|
152
|
+
|
153
|
+
try:
|
154
|
+
response = await asyncio.to_thread(
|
155
|
+
service.users()
|
156
|
+
.messages()
|
157
|
+
.list(userId="me", q=query, maxResults=page_size)
|
158
|
+
.execute
|
159
|
+
)
|
160
|
+
messages = response.get("messages", [])
|
161
|
+
formatted_output = _format_gmail_results_plain(messages, query)
|
162
|
+
|
163
|
+
logger.info(f"[search_gmail_messages] Found {len(messages)} messages")
|
164
|
+
return formatted_output
|
165
|
+
|
166
|
+
except HttpError as e:
|
167
|
+
error_msg = f"Gmail API error: {e.reason}" if e.resp.status != 400 else f"Invalid query: '{query}'"
|
168
|
+
logger.error(f"[search_gmail_messages] {error_msg}")
|
169
|
+
raise Exception(error_msg)
|
170
|
+
except Exception as e:
|
171
|
+
error_msg = f"Error searching Gmail: {str(e)}"
|
172
|
+
logger.error(f"[search_gmail_messages] {error_msg}")
|
173
|
+
raise Exception(error_msg)
|
174
|
+
|
175
|
+
|
176
|
+
@server.tool()
|
177
|
+
@require_google_service("gmail", "gmail_read")
|
178
|
+
async def get_gmail_message_content(
|
179
|
+
service, message_id: str, user_google_email: str
|
180
|
+
) -> str:
|
181
|
+
"""
|
182
|
+
Retrieves the full content (subject, sender, plain text body) of a specific Gmail message.
|
183
|
+
|
184
|
+
Args:
|
185
|
+
message_id (str): The unique ID of the Gmail message to retrieve.
|
186
|
+
user_google_email (str): The user's Google email address. Required.
|
187
|
+
|
188
|
+
Returns:
|
189
|
+
str: The message details including subject, sender, and body content.
|
190
|
+
"""
|
191
|
+
logger.info(
|
192
|
+
f"[get_gmail_message_content] Invoked. Message ID: '{message_id}', Email: '{user_google_email}'"
|
193
|
+
)
|
194
|
+
|
195
|
+
try:
|
196
|
+
logger.info(f"[get_gmail_message_content] Using service for: {user_google_email}")
|
197
|
+
|
198
|
+
# Fetch message metadata first to get headers
|
199
|
+
message_metadata = await asyncio.to_thread(
|
200
|
+
service.users()
|
201
|
+
.messages()
|
202
|
+
.get(
|
203
|
+
userId="me",
|
204
|
+
id=message_id,
|
205
|
+
format="metadata",
|
206
|
+
metadataHeaders=["Subject", "From"],
|
207
|
+
)
|
208
|
+
.execute
|
209
|
+
)
|
210
|
+
|
211
|
+
headers = {
|
212
|
+
h["name"]: h["value"]
|
213
|
+
for h in message_metadata.get("payload", {}).get("headers", [])
|
214
|
+
}
|
215
|
+
subject = headers.get("Subject", "(no subject)")
|
216
|
+
sender = headers.get("From", "(unknown sender)")
|
217
|
+
|
218
|
+
# Now fetch the full message to get the body parts
|
219
|
+
message_full = await asyncio.to_thread(
|
220
|
+
service.users()
|
221
|
+
.messages()
|
222
|
+
.get(
|
223
|
+
userId="me",
|
224
|
+
id=message_id,
|
225
|
+
format="full", # Request full payload for body
|
226
|
+
)
|
227
|
+
.execute
|
228
|
+
)
|
229
|
+
|
230
|
+
# Extract the plain text body using helper function
|
231
|
+
payload = message_full.get("payload", {})
|
232
|
+
body_data = _extract_message_body(payload)
|
233
|
+
|
234
|
+
content_text = "\n".join(
|
235
|
+
[
|
236
|
+
f"Subject: {subject}",
|
237
|
+
f"From: {sender}",
|
238
|
+
f"\n--- BODY ---\n{body_data or '[No text/plain body found]'}",
|
239
|
+
]
|
240
|
+
)
|
241
|
+
return content_text
|
242
|
+
|
243
|
+
except HttpError as e:
|
244
|
+
logger.error(
|
245
|
+
f"[get_gmail_message_content] Gmail API error getting message content: {e}", exc_info=True
|
246
|
+
)
|
247
|
+
raise Exception(f"Gmail API error: {e}")
|
248
|
+
except Exception as e:
|
249
|
+
logger.exception(
|
250
|
+
f"[get_gmail_message_content] Unexpected error getting Gmail message content: {e}"
|
251
|
+
)
|
252
|
+
raise Exception(f"Unexpected error: {e}")
|
253
|
+
|
254
|
+
|
255
|
+
@server.tool()
|
256
|
+
@require_google_service("gmail", "gmail_read")
|
257
|
+
async def get_gmail_messages_content_batch(
|
258
|
+
service,
|
259
|
+
message_ids: List[str],
|
260
|
+
user_google_email: str,
|
261
|
+
format: Literal["full", "metadata"] = "full",
|
262
|
+
) -> str:
|
263
|
+
"""
|
264
|
+
Retrieves the content of multiple Gmail messages in a single batch request.
|
265
|
+
Supports up to 100 messages per request using Google's batch API.
|
266
|
+
|
267
|
+
Args:
|
268
|
+
message_ids (List[str]): List of Gmail message IDs to retrieve (max 100).
|
269
|
+
user_google_email (str): The user's Google email address. Required.
|
270
|
+
format (Literal["full", "metadata"]): Message format. "full" includes body, "metadata" only headers.
|
271
|
+
|
272
|
+
Returns:
|
273
|
+
str: A formatted list of message contents with separators.
|
274
|
+
"""
|
275
|
+
logger.info(
|
276
|
+
f"[get_gmail_messages_content_batch] Invoked. Message count: {len(message_ids)}, Email: '{user_google_email}'"
|
277
|
+
)
|
278
|
+
|
279
|
+
if not message_ids:
|
280
|
+
raise Exception("No message IDs provided")
|
281
|
+
|
282
|
+
try:
|
283
|
+
output_messages = []
|
284
|
+
|
285
|
+
# Process in chunks of 100 (Gmail batch limit)
|
286
|
+
for chunk_start in range(0, len(message_ids), 100):
|
287
|
+
chunk_ids = message_ids[chunk_start:chunk_start + 100]
|
288
|
+
results: Dict[str, Dict] = {}
|
289
|
+
|
290
|
+
def _batch_callback(request_id, response, exception):
|
291
|
+
"""Callback for batch requests"""
|
292
|
+
results[request_id] = {"data": response, "error": exception}
|
293
|
+
|
294
|
+
# Try to use batch API
|
295
|
+
try:
|
296
|
+
batch = service.new_batch_http_request(callback=_batch_callback)
|
297
|
+
|
298
|
+
for mid in chunk_ids:
|
299
|
+
if format == "metadata":
|
300
|
+
req = service.users().messages().get(
|
301
|
+
userId="me",
|
302
|
+
id=mid,
|
303
|
+
format="metadata",
|
304
|
+
metadataHeaders=["Subject", "From"]
|
305
|
+
)
|
306
|
+
else:
|
307
|
+
req = service.users().messages().get(
|
308
|
+
userId="me",
|
309
|
+
id=mid,
|
310
|
+
format="full"
|
311
|
+
)
|
312
|
+
batch.add(req, request_id=mid)
|
313
|
+
|
314
|
+
# Execute batch request
|
315
|
+
await asyncio.to_thread(batch.execute)
|
316
|
+
|
317
|
+
except Exception as batch_error:
|
318
|
+
# Fallback to asyncio.gather if batch API fails
|
319
|
+
logger.warning(
|
320
|
+
f"[get_gmail_messages_content_batch] Batch API failed, falling back to asyncio.gather: {batch_error}"
|
321
|
+
)
|
322
|
+
|
323
|
+
async def fetch_message(mid: str):
|
324
|
+
try:
|
325
|
+
if format == "metadata":
|
326
|
+
msg = await asyncio.to_thread(
|
327
|
+
service.users().messages().get(
|
328
|
+
userId="me",
|
329
|
+
id=mid,
|
330
|
+
format="metadata",
|
331
|
+
metadataHeaders=["Subject", "From"]
|
332
|
+
).execute
|
333
|
+
)
|
334
|
+
else:
|
335
|
+
msg = await asyncio.to_thread(
|
336
|
+
service.users().messages().get(
|
337
|
+
userId="me",
|
338
|
+
id=mid,
|
339
|
+
format="full"
|
340
|
+
).execute
|
341
|
+
)
|
342
|
+
return mid, msg, None
|
343
|
+
except Exception as e:
|
344
|
+
return mid, None, e
|
345
|
+
|
346
|
+
# Fetch all messages in parallel
|
347
|
+
fetch_results = await asyncio.gather(
|
348
|
+
*[fetch_message(mid) for mid in chunk_ids],
|
349
|
+
return_exceptions=False
|
350
|
+
)
|
351
|
+
|
352
|
+
# Convert to results format
|
353
|
+
for mid, msg, error in fetch_results:
|
354
|
+
results[mid] = {"data": msg, "error": error}
|
355
|
+
|
356
|
+
# Process results for this chunk
|
357
|
+
for mid in chunk_ids:
|
358
|
+
entry = results.get(mid, {"data": None, "error": "No result"})
|
359
|
+
|
360
|
+
if entry["error"]:
|
361
|
+
output_messages.append(
|
362
|
+
f"⚠️ Message {mid}: {entry['error']}\n"
|
363
|
+
)
|
364
|
+
else:
|
365
|
+
message = entry["data"]
|
366
|
+
if not message:
|
367
|
+
output_messages.append(
|
368
|
+
f"⚠️ Message {mid}: No data returned\n"
|
369
|
+
)
|
370
|
+
continue
|
371
|
+
|
372
|
+
# Extract content based on format
|
373
|
+
payload = message.get("payload", {})
|
374
|
+
|
375
|
+
if format == "metadata":
|
376
|
+
headers = _extract_headers(payload, ["Subject", "From"])
|
377
|
+
subject = headers.get("Subject", "(no subject)")
|
378
|
+
sender = headers.get("From", "(unknown sender)")
|
379
|
+
|
380
|
+
output_messages.append(
|
381
|
+
f"Message ID: {mid}\n"
|
382
|
+
f"Subject: {subject}\n"
|
383
|
+
f"From: {sender}\n"
|
384
|
+
f"Web Link: {_generate_gmail_web_url(mid)}\n"
|
385
|
+
)
|
386
|
+
else:
|
387
|
+
# Full format - extract body too
|
388
|
+
headers = _extract_headers(payload, ["Subject", "From"])
|
389
|
+
subject = headers.get("Subject", "(no subject)")
|
390
|
+
sender = headers.get("From", "(unknown sender)")
|
391
|
+
body = _extract_message_body(payload)
|
392
|
+
|
393
|
+
output_messages.append(
|
394
|
+
f"Message ID: {mid}\n"
|
395
|
+
f"Subject: {subject}\n"
|
396
|
+
f"From: {sender}\n"
|
397
|
+
f"Web Link: {_generate_gmail_web_url(mid)}\n"
|
398
|
+
f"\n{body or '[No text/plain body found]'}\n"
|
399
|
+
)
|
400
|
+
|
401
|
+
# Combine all messages with separators
|
402
|
+
final_output = f"Retrieved {len(message_ids)} messages:\n\n"
|
403
|
+
final_output += "\n---\n\n".join(output_messages)
|
404
|
+
|
405
|
+
return final_output
|
406
|
+
|
407
|
+
except HttpError as e:
|
408
|
+
logger.error(
|
409
|
+
f"[get_gmail_messages_content_batch] Gmail API error in batch retrieval: {e}", exc_info=True
|
410
|
+
)
|
411
|
+
raise Exception(f"Gmail API error: {e}")
|
412
|
+
except Exception as e:
|
413
|
+
logger.exception(
|
414
|
+
f"[get_gmail_messages_content_batch] Unexpected error in batch retrieval: {e}"
|
415
|
+
)
|
416
|
+
raise Exception(f"Unexpected error: {e}")
|
417
|
+
|
418
|
+
|
419
|
+
@server.tool()
|
420
|
+
@require_google_service("gmail", GMAIL_SEND_SCOPE)
|
421
|
+
async def send_gmail_message(
|
422
|
+
service,
|
423
|
+
user_google_email: str,
|
424
|
+
to: str = Body(..., description="Recipient email address."),
|
425
|
+
subject: str = Body(..., description="Email subject."),
|
426
|
+
body: str = Body(..., description="Email body (plain text)."),
|
427
|
+
) -> str:
|
428
|
+
"""
|
429
|
+
Sends an email using the user's Gmail account.
|
430
|
+
|
431
|
+
Args:
|
432
|
+
to (str): Recipient email address.
|
433
|
+
subject (str): Email subject.
|
434
|
+
body (str): Email body (plain text).
|
435
|
+
user_google_email (str): The user's Google email address. Required.
|
436
|
+
|
437
|
+
Returns:
|
438
|
+
str: Confirmation message with the sent email's message ID.
|
439
|
+
"""
|
440
|
+
try:
|
441
|
+
# Prepare the email
|
442
|
+
message = MIMEText(body)
|
443
|
+
message["to"] = to
|
444
|
+
message["subject"] = subject
|
445
|
+
raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode()
|
446
|
+
send_body = {"raw": raw_message}
|
447
|
+
|
448
|
+
# Send the message
|
449
|
+
sent_message = await asyncio.to_thread(
|
450
|
+
service.users().messages().send(userId="me", body=send_body).execute
|
451
|
+
)
|
452
|
+
message_id = sent_message.get("id")
|
453
|
+
return f"Email sent! Message ID: {message_id}"
|
454
|
+
|
455
|
+
except HttpError as e:
|
456
|
+
logger.error(
|
457
|
+
f"[send_gmail_message] Gmail API error sending message: {e}", exc_info=True
|
458
|
+
)
|
459
|
+
raise Exception(f"Gmail API error: {e}")
|
460
|
+
except Exception as e:
|
461
|
+
logger.exception(f"[send_gmail_message] Unexpected error sending Gmail message: {e}")
|
462
|
+
raise Exception(f"Unexpected error: {e}")
|
463
|
+
|
464
|
+
|
465
|
+
@server.tool()
|
466
|
+
@require_google_service("gmail", GMAIL_COMPOSE_SCOPE)
|
467
|
+
async def draft_gmail_message(
|
468
|
+
service,
|
469
|
+
user_google_email: str,
|
470
|
+
subject: str = Body(..., description="Email subject."),
|
471
|
+
body: str = Body(..., description="Email body (plain text)."),
|
472
|
+
to: Optional[str] = Body(None, description="Optional recipient email address."),
|
473
|
+
) -> str:
|
474
|
+
"""
|
475
|
+
Creates a draft email in the user's Gmail account.
|
476
|
+
|
477
|
+
Args:
|
478
|
+
user_google_email (str): The user's Google email address. Required.
|
479
|
+
subject (str): Email subject.
|
480
|
+
body (str): Email body (plain text).
|
481
|
+
to (Optional[str]): Optional recipient email address. Can be left empty for drafts.
|
482
|
+
|
483
|
+
Returns:
|
484
|
+
str: Confirmation message with the created draft's ID.
|
485
|
+
"""
|
486
|
+
logger.info(
|
487
|
+
f"[draft_gmail_message] Invoked. Email: '{user_google_email}', Subject: '{subject}'"
|
488
|
+
)
|
489
|
+
|
490
|
+
try:
|
491
|
+
# Prepare the email
|
492
|
+
message = MIMEText(body)
|
493
|
+
message["subject"] = subject
|
494
|
+
|
495
|
+
# Add recipient if provided
|
496
|
+
if to:
|
497
|
+
message["to"] = to
|
498
|
+
|
499
|
+
raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode()
|
500
|
+
|
501
|
+
# Create a draft instead of sending
|
502
|
+
draft_body = {"message": {"raw": raw_message}}
|
503
|
+
|
504
|
+
# Create the draft
|
505
|
+
created_draft = await asyncio.to_thread(
|
506
|
+
service.users().drafts().create(userId="me", body=draft_body).execute
|
507
|
+
)
|
508
|
+
draft_id = created_draft.get("id")
|
509
|
+
return f"Draft created! Draft ID: {draft_id}"
|
510
|
+
|
511
|
+
except HttpError as e:
|
512
|
+
logger.error(
|
513
|
+
f"[draft_gmail_message] Gmail API error creating draft: {e}", exc_info=True
|
514
|
+
)
|
515
|
+
raise Exception(f"Gmail API error: {e}")
|
516
|
+
except Exception as e:
|
517
|
+
logger.exception(f"[draft_gmail_message] Unexpected error creating Gmail draft: {e}")
|
518
|
+
raise Exception(f"Unexpected error: {e}")
|
519
|
+
|
520
|
+
|
521
|
+
@server.tool()
|
522
|
+
@require_google_service("gmail", "gmail_read")
|
523
|
+
async def get_gmail_thread_content(
|
524
|
+
service, thread_id: str, user_google_email: str
|
525
|
+
) -> str:
|
526
|
+
"""
|
527
|
+
Retrieves the complete content of a Gmail conversation thread, including all messages.
|
528
|
+
|
529
|
+
Args:
|
530
|
+
thread_id (str): The unique ID of the Gmail thread to retrieve.
|
531
|
+
user_google_email (str): The user's Google email address. Required.
|
532
|
+
|
533
|
+
Returns:
|
534
|
+
str: The complete thread content with all messages formatted for reading.
|
535
|
+
"""
|
536
|
+
logger.info(
|
537
|
+
f"[get_gmail_thread_content] Invoked. Thread ID: '{thread_id}', Email: '{user_google_email}'"
|
538
|
+
)
|
539
|
+
|
540
|
+
try:
|
541
|
+
# Fetch the complete thread with all messages
|
542
|
+
thread_response = await asyncio.to_thread(
|
543
|
+
service.users()
|
544
|
+
.threads()
|
545
|
+
.get(userId="me", id=thread_id, format="full")
|
546
|
+
.execute
|
547
|
+
)
|
548
|
+
|
549
|
+
messages = thread_response.get("messages", [])
|
550
|
+
if not messages:
|
551
|
+
return f"No messages found in thread '{thread_id}'."
|
552
|
+
|
553
|
+
# Extract thread subject from the first message
|
554
|
+
first_message = messages[0]
|
555
|
+
first_headers = {
|
556
|
+
h["name"]: h["value"]
|
557
|
+
for h in first_message.get("payload", {}).get("headers", [])
|
558
|
+
}
|
559
|
+
thread_subject = first_headers.get("Subject", "(no subject)")
|
560
|
+
|
561
|
+
# Build the thread content
|
562
|
+
content_lines = [
|
563
|
+
f"Thread ID: {thread_id}",
|
564
|
+
f"Subject: {thread_subject}",
|
565
|
+
f"Messages: {len(messages)}",
|
566
|
+
"",
|
567
|
+
]
|
568
|
+
|
569
|
+
# Process each message in the thread
|
570
|
+
for i, message in enumerate(messages, 1):
|
571
|
+
# Extract headers
|
572
|
+
headers = {
|
573
|
+
h["name"]: h["value"]
|
574
|
+
for h in message.get("payload", {}).get("headers", [])
|
575
|
+
}
|
576
|
+
|
577
|
+
sender = headers.get("From", "(unknown sender)")
|
578
|
+
date = headers.get("Date", "(unknown date)")
|
579
|
+
subject = headers.get("Subject", "(no subject)")
|
580
|
+
|
581
|
+
# Extract message body
|
582
|
+
payload = message.get("payload", {})
|
583
|
+
body_data = _extract_message_body(payload)
|
584
|
+
|
585
|
+
# Add message to content
|
586
|
+
content_lines.extend(
|
587
|
+
[
|
588
|
+
f"=== Message {i} ===",
|
589
|
+
f"From: {sender}",
|
590
|
+
f"Date: {date}",
|
591
|
+
]
|
592
|
+
)
|
593
|
+
|
594
|
+
# Only show subject if it's different from thread subject
|
595
|
+
if subject != thread_subject:
|
596
|
+
content_lines.append(f"Subject: {subject}")
|
597
|
+
|
598
|
+
content_lines.extend(
|
599
|
+
[
|
600
|
+
"",
|
601
|
+
body_data or "[No text/plain body found]",
|
602
|
+
"",
|
603
|
+
]
|
604
|
+
)
|
605
|
+
|
606
|
+
content_text = "\n".join(content_lines)
|
607
|
+
return content_text
|
608
|
+
|
609
|
+
except HttpError as e:
|
610
|
+
logger.error(
|
611
|
+
f"[get_gmail_thread_content] Gmail API error getting thread content: {e}", exc_info=True
|
612
|
+
)
|
613
|
+
raise Exception(f"Gmail API error: {e}")
|
614
|
+
except Exception as e:
|
615
|
+
logger.exception(
|
616
|
+
f"[get_gmail_thread_content] Unexpected error getting Gmail thread content: {e}"
|
617
|
+
)
|
618
|
+
raise Exception(f"Unexpected error: {e}")
|
619
|
+
|
620
|
+
|
621
|
+
@server.tool()
|
622
|
+
@require_google_service("gmail", "gmail_read")
|
623
|
+
async def list_gmail_labels(service, user_google_email: str) -> str:
|
624
|
+
"""
|
625
|
+
Lists all labels in the user's Gmail account.
|
626
|
+
|
627
|
+
Args:
|
628
|
+
user_google_email (str): The user's Google email address. Required.
|
629
|
+
|
630
|
+
Returns:
|
631
|
+
str: A formatted list of all labels with their IDs, names, and types.
|
632
|
+
"""
|
633
|
+
logger.info(f"[list_gmail_labels] Invoked. Email: '{user_google_email}'")
|
634
|
+
|
635
|
+
try:
|
636
|
+
response = await asyncio.to_thread(
|
637
|
+
service.users().labels().list(userId="me").execute
|
638
|
+
)
|
639
|
+
labels = response.get("labels", [])
|
640
|
+
|
641
|
+
if not labels:
|
642
|
+
return "No labels found."
|
643
|
+
|
644
|
+
lines = [f"Found {len(labels)} labels:", ""]
|
645
|
+
|
646
|
+
system_labels = []
|
647
|
+
user_labels = []
|
648
|
+
|
649
|
+
for label in labels:
|
650
|
+
if label.get("type") == "system":
|
651
|
+
system_labels.append(label)
|
652
|
+
else:
|
653
|
+
user_labels.append(label)
|
654
|
+
|
655
|
+
if system_labels:
|
656
|
+
lines.append("📂 SYSTEM LABELS:")
|
657
|
+
for label in system_labels:
|
658
|
+
lines.append(f" • {label['name']} (ID: {label['id']})")
|
659
|
+
lines.append("")
|
660
|
+
|
661
|
+
if user_labels:
|
662
|
+
lines.append("🏷️ USER LABELS:")
|
663
|
+
for label in user_labels:
|
664
|
+
lines.append(f" • {label['name']} (ID: {label['id']})")
|
665
|
+
|
666
|
+
return "\n".join(lines)
|
667
|
+
|
668
|
+
except HttpError as e:
|
669
|
+
logger.error(f"[list_gmail_labels] Gmail API error listing labels: {e}", exc_info=True)
|
670
|
+
raise Exception(f"Gmail API error: {e}")
|
671
|
+
except Exception as e:
|
672
|
+
logger.exception(f"[list_gmail_labels] Unexpected error listing Gmail labels: {e}")
|
673
|
+
raise Exception(f"Unexpected error: {e}")
|
674
|
+
|
675
|
+
|
676
|
+
@server.tool()
|
677
|
+
@require_google_service("gmail", GMAIL_LABELS_SCOPE)
|
678
|
+
async def manage_gmail_label(
|
679
|
+
service,
|
680
|
+
user_google_email: str,
|
681
|
+
action: Literal["create", "update", "delete"],
|
682
|
+
name: Optional[str] = None,
|
683
|
+
label_id: Optional[str] = None,
|
684
|
+
label_list_visibility: Literal["labelShow", "labelHide"] = "labelShow",
|
685
|
+
message_list_visibility: Literal["show", "hide"] = "show",
|
686
|
+
) -> str:
|
687
|
+
"""
|
688
|
+
Manages Gmail labels: create, update, or delete labels.
|
689
|
+
|
690
|
+
Args:
|
691
|
+
user_google_email (str): The user's Google email address. Required.
|
692
|
+
action (Literal["create", "update", "delete"]): Action to perform on the label.
|
693
|
+
name (Optional[str]): Label name. Required for create, optional for update.
|
694
|
+
label_id (Optional[str]): Label ID. Required for update and delete operations.
|
695
|
+
label_list_visibility (Literal["labelShow", "labelHide"]): Whether the label is shown in the label list.
|
696
|
+
message_list_visibility (Literal["show", "hide"]): Whether the label is shown in the message list.
|
697
|
+
|
698
|
+
Returns:
|
699
|
+
str: Confirmation message of the label operation.
|
700
|
+
"""
|
701
|
+
logger.info(f"[manage_gmail_label] Invoked. Email: '{user_google_email}', Action: '{action}'")
|
702
|
+
|
703
|
+
if action == "create" and not name:
|
704
|
+
raise Exception("Label name is required for create action.")
|
705
|
+
|
706
|
+
if action in ["update", "delete"] and not label_id:
|
707
|
+
raise Exception("Label ID is required for update and delete actions.")
|
708
|
+
|
709
|
+
try:
|
710
|
+
if action == "create":
|
711
|
+
label_object = {
|
712
|
+
"name": name,
|
713
|
+
"labelListVisibility": label_list_visibility,
|
714
|
+
"messageListVisibility": message_list_visibility,
|
715
|
+
}
|
716
|
+
created_label = await asyncio.to_thread(
|
717
|
+
service.users().labels().create(userId="me", body=label_object).execute
|
718
|
+
)
|
719
|
+
return f"Label created successfully!\nName: {created_label['name']}\nID: {created_label['id']}"
|
720
|
+
|
721
|
+
elif action == "update":
|
722
|
+
current_label = await asyncio.to_thread(
|
723
|
+
service.users().labels().get(userId="me", id=label_id).execute
|
724
|
+
)
|
725
|
+
|
726
|
+
label_object = {
|
727
|
+
"id": label_id,
|
728
|
+
"name": name if name is not None else current_label["name"],
|
729
|
+
"labelListVisibility": label_list_visibility,
|
730
|
+
"messageListVisibility": message_list_visibility,
|
731
|
+
}
|
732
|
+
|
733
|
+
updated_label = await asyncio.to_thread(
|
734
|
+
service.users().labels().update(userId="me", id=label_id, body=label_object).execute
|
735
|
+
)
|
736
|
+
return f"Label updated successfully!\nName: {updated_label['name']}\nID: {updated_label['id']}"
|
737
|
+
|
738
|
+
elif action == "delete":
|
739
|
+
label = await asyncio.to_thread(
|
740
|
+
service.users().labels().get(userId="me", id=label_id).execute
|
741
|
+
)
|
742
|
+
label_name = label["name"]
|
743
|
+
|
744
|
+
await asyncio.to_thread(
|
745
|
+
service.users().labels().delete(userId="me", id=label_id).execute
|
746
|
+
)
|
747
|
+
return f"Label '{label_name}' (ID: {label_id}) deleted successfully!"
|
748
|
+
|
749
|
+
except HttpError as e:
|
750
|
+
logger.error(f"[manage_gmail_label] Gmail API error: {e}", exc_info=True)
|
751
|
+
raise Exception(f"Gmail API error: {e}")
|
752
|
+
except Exception as e:
|
753
|
+
logger.exception(f"[manage_gmail_label] Unexpected error: {e}")
|
754
|
+
raise Exception(f"Unexpected error: {e}")
|
755
|
+
|
756
|
+
|
757
|
+
@server.tool()
|
758
|
+
@require_google_service("gmail", GMAIL_MODIFY_SCOPE)
|
759
|
+
async def modify_gmail_message_labels(
|
760
|
+
service,
|
761
|
+
user_google_email: str,
|
762
|
+
message_id: str,
|
763
|
+
add_label_ids: Optional[List[str]] = None,
|
764
|
+
remove_label_ids: Optional[List[str]] = None,
|
765
|
+
) -> str:
|
766
|
+
"""
|
767
|
+
Adds or removes labels from a Gmail message.
|
768
|
+
|
769
|
+
Args:
|
770
|
+
user_google_email (str): The user's Google email address. Required.
|
771
|
+
message_id (str): The ID of the message to modify.
|
772
|
+
add_label_ids (Optional[List[str]]): List of label IDs to add to the message.
|
773
|
+
remove_label_ids (Optional[List[str]]): List of label IDs to remove from the message.
|
774
|
+
|
775
|
+
Returns:
|
776
|
+
str: Confirmation message of the label changes applied to the message.
|
777
|
+
"""
|
778
|
+
logger.info(f"[modify_gmail_message_labels] Invoked. Email: '{user_google_email}', Message ID: '{message_id}'")
|
779
|
+
|
780
|
+
if not add_label_ids and not remove_label_ids:
|
781
|
+
raise Exception("At least one of add_label_ids or remove_label_ids must be provided.")
|
782
|
+
|
783
|
+
try:
|
784
|
+
body = {}
|
785
|
+
if add_label_ids:
|
786
|
+
body["addLabelIds"] = add_label_ids
|
787
|
+
if remove_label_ids:
|
788
|
+
body["removeLabelIds"] = remove_label_ids
|
789
|
+
|
790
|
+
await asyncio.to_thread(
|
791
|
+
service.users().messages().modify(userId="me", id=message_id, body=body).execute
|
792
|
+
)
|
793
|
+
|
794
|
+
actions = []
|
795
|
+
if add_label_ids:
|
796
|
+
actions.append(f"Added labels: {', '.join(add_label_ids)}")
|
797
|
+
if remove_label_ids:
|
798
|
+
actions.append(f"Removed labels: {', '.join(remove_label_ids)}")
|
799
|
+
|
800
|
+
return f"Message labels updated successfully!\nMessage ID: {message_id}\n{'; '.join(actions)}"
|
801
|
+
|
802
|
+
except HttpError as e:
|
803
|
+
logger.error(f"[modify_gmail_message_labels] Gmail API error modifying message labels: {e}", exc_info=True)
|
804
|
+
raise Exception(f"Gmail API error: {e}")
|
805
|
+
except Exception as e:
|
806
|
+
logger.exception(f"[modify_gmail_message_labels] Unexpected error modifying Gmail message labels: {e}")
|
807
|
+
raise Exception(f"Unexpected error: {e}")
|