google-workspace-mcp 1.0.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.
- google_workspace_mcp/__init__.py +3 -0
- google_workspace_mcp/__main__.py +43 -0
- google_workspace_mcp/app.py +8 -0
- google_workspace_mcp/auth/__init__.py +7 -0
- google_workspace_mcp/auth/gauth.py +62 -0
- google_workspace_mcp/config.py +60 -0
- google_workspace_mcp/prompts/__init__.py +3 -0
- google_workspace_mcp/prompts/calendar.py +36 -0
- google_workspace_mcp/prompts/drive.py +18 -0
- google_workspace_mcp/prompts/gmail.py +65 -0
- google_workspace_mcp/prompts/slides.py +40 -0
- google_workspace_mcp/resources/__init__.py +13 -0
- google_workspace_mcp/resources/calendar.py +79 -0
- google_workspace_mcp/resources/drive.py +93 -0
- google_workspace_mcp/resources/gmail.py +58 -0
- google_workspace_mcp/resources/sheets_resources.py +92 -0
- google_workspace_mcp/resources/slides.py +421 -0
- google_workspace_mcp/services/__init__.py +21 -0
- google_workspace_mcp/services/base.py +73 -0
- google_workspace_mcp/services/calendar.py +256 -0
- google_workspace_mcp/services/docs_service.py +388 -0
- google_workspace_mcp/services/drive.py +454 -0
- google_workspace_mcp/services/gmail.py +676 -0
- google_workspace_mcp/services/sheets_service.py +466 -0
- google_workspace_mcp/services/slides.py +959 -0
- google_workspace_mcp/tools/__init__.py +7 -0
- google_workspace_mcp/tools/calendar.py +229 -0
- google_workspace_mcp/tools/docs_tools.py +277 -0
- google_workspace_mcp/tools/drive.py +221 -0
- google_workspace_mcp/tools/gmail.py +344 -0
- google_workspace_mcp/tools/sheets_tools.py +322 -0
- google_workspace_mcp/tools/slides.py +478 -0
- google_workspace_mcp/utils/__init__.py +1 -0
- google_workspace_mcp/utils/markdown_slides.py +504 -0
- google_workspace_mcp-1.0.0.dist-info/METADATA +547 -0
- google_workspace_mcp-1.0.0.dist-info/RECORD +38 -0
- google_workspace_mcp-1.0.0.dist-info/WHEEL +4 -0
- google_workspace_mcp-1.0.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,676 @@
|
|
1
|
+
"""
|
2
|
+
Google Gmail service implementation.
|
3
|
+
"""
|
4
|
+
|
5
|
+
import base64
|
6
|
+
import logging
|
7
|
+
import time
|
8
|
+
import traceback
|
9
|
+
from email.mime.text import MIMEText
|
10
|
+
from typing import Any
|
11
|
+
|
12
|
+
from google_workspace_mcp.services.base import BaseGoogleService
|
13
|
+
|
14
|
+
logger = logging.getLogger(__name__)
|
15
|
+
|
16
|
+
|
17
|
+
class GmailService(BaseGoogleService):
|
18
|
+
"""
|
19
|
+
Service for interacting with Gmail API.
|
20
|
+
"""
|
21
|
+
|
22
|
+
def __init__(self):
|
23
|
+
"""Initialize the Gmail service."""
|
24
|
+
super().__init__("gmail", "v1")
|
25
|
+
|
26
|
+
def query_emails(self, query: str | None = None, max_results: int = 100) -> list[dict[str, Any]]:
|
27
|
+
"""
|
28
|
+
Query emails from Gmail based on a search query with pagination support.
|
29
|
+
|
30
|
+
Args:
|
31
|
+
query: Gmail search query (e.g., 'is:unread', 'from:example@gmail.com')
|
32
|
+
max_results: Maximum number of emails to retrieve (will be paginated if needed)
|
33
|
+
|
34
|
+
Returns:
|
35
|
+
List of parsed email message dictionaries
|
36
|
+
"""
|
37
|
+
try:
|
38
|
+
# Ensure max_results is within reasonable limits
|
39
|
+
absolute_max = 1000 # Set a reasonable upper limit
|
40
|
+
max_results = min(max(1, max_results), absolute_max)
|
41
|
+
|
42
|
+
# Initialize result container
|
43
|
+
messages = []
|
44
|
+
next_page_token = None
|
45
|
+
results_fetched = 0
|
46
|
+
|
47
|
+
# Loop until we have enough results or run out of pages
|
48
|
+
while results_fetched < max_results:
|
49
|
+
# Calculate how many results to request in this page
|
50
|
+
page_size = min(100, max_results - results_fetched) # Gmail API max page size is 100
|
51
|
+
|
52
|
+
# Make the API request
|
53
|
+
request_params = {
|
54
|
+
"userId": "me",
|
55
|
+
"maxResults": page_size,
|
56
|
+
"q": query if query else "",
|
57
|
+
}
|
58
|
+
|
59
|
+
# Add pageToken if we're not on the first page
|
60
|
+
if next_page_token:
|
61
|
+
request_params["pageToken"] = next_page_token
|
62
|
+
|
63
|
+
# Get this page of message IDs
|
64
|
+
result = self.service.users().messages().list(**request_params).execute()
|
65
|
+
|
66
|
+
# Extract messages and nextPageToken
|
67
|
+
page_messages = result.get("messages", [])
|
68
|
+
next_page_token = result.get("nextPageToken")
|
69
|
+
|
70
|
+
# If no messages found or no more pages, exit the loop
|
71
|
+
if not page_messages:
|
72
|
+
break
|
73
|
+
|
74
|
+
# Fetch full message details for each message in this page
|
75
|
+
for msg in page_messages:
|
76
|
+
try:
|
77
|
+
txt = self.service.users().messages().get(userId="me", id=msg["id"]).execute()
|
78
|
+
parsed_message = self._parse_message(txt=txt, parse_body=False)
|
79
|
+
if parsed_message:
|
80
|
+
messages.append(parsed_message)
|
81
|
+
results_fetched += 1
|
82
|
+
except Exception as e:
|
83
|
+
logger.warning(f"Error fetching message {msg['id']}: {e}")
|
84
|
+
|
85
|
+
# If no more pages or we've reached max_results, exit the loop
|
86
|
+
if not next_page_token or results_fetched >= max_results:
|
87
|
+
break
|
88
|
+
|
89
|
+
return messages
|
90
|
+
|
91
|
+
except Exception as e:
|
92
|
+
return self.handle_api_error("query_emails", e)
|
93
|
+
|
94
|
+
def get_email_by_id(self, email_id: str, parse_body: bool = True) -> dict[str, Any] | None:
|
95
|
+
"""
|
96
|
+
Get a single email by its ID.
|
97
|
+
|
98
|
+
Args:
|
99
|
+
email_id: The ID of the email to retrieve
|
100
|
+
parse_body: Whether to parse and include the message body
|
101
|
+
|
102
|
+
Returns:
|
103
|
+
Email data dictionary if successful
|
104
|
+
"""
|
105
|
+
try:
|
106
|
+
message = self.service.users().messages().get(userId="me", id=email_id).execute()
|
107
|
+
return self._parse_message(message, parse_body=parse_body)
|
108
|
+
|
109
|
+
except Exception as e:
|
110
|
+
return self.handle_api_error("get_email_by_id", e)
|
111
|
+
|
112
|
+
def get_email(self, email_id: str) -> dict[str, Any] | None:
|
113
|
+
"""
|
114
|
+
Get a single email by its ID (wrapper for compatibility).
|
115
|
+
|
116
|
+
Args:
|
117
|
+
email_id: The ID of the email to retrieve
|
118
|
+
|
119
|
+
Returns:
|
120
|
+
Email data dictionary if successful
|
121
|
+
"""
|
122
|
+
return self.get_email_by_id(email_id, parse_body=True)
|
123
|
+
|
124
|
+
def get_email_with_attachments(self, email_id: str) -> tuple[dict[str, Any] | None, dict[str, dict[str, Any]]]:
|
125
|
+
"""
|
126
|
+
Get an email with its attachments.
|
127
|
+
|
128
|
+
Args:
|
129
|
+
email_id: The ID of the email to retrieve
|
130
|
+
|
131
|
+
Returns:
|
132
|
+
Tuple of (email_data, attachments_dict)
|
133
|
+
"""
|
134
|
+
try:
|
135
|
+
# Get the email message
|
136
|
+
message = self.service.users().messages().get(userId="me", id=email_id).execute()
|
137
|
+
email_data = self._parse_message(message, parse_body=True)
|
138
|
+
|
139
|
+
if not email_data:
|
140
|
+
return None, {}
|
141
|
+
|
142
|
+
# Extract attachment information
|
143
|
+
attachments = {}
|
144
|
+
payload = message.get("payload", {})
|
145
|
+
|
146
|
+
def extract_attachments(part, attachments_dict):
|
147
|
+
if "parts" in part:
|
148
|
+
for subpart in part["parts"]:
|
149
|
+
extract_attachments(subpart, attachments_dict)
|
150
|
+
elif part.get("filename") and part.get("body", {}).get("attachmentId"):
|
151
|
+
attachment_id = part["body"]["attachmentId"]
|
152
|
+
attachments_dict[attachment_id] = {
|
153
|
+
"filename": part["filename"],
|
154
|
+
"mimeType": part.get("mimeType"),
|
155
|
+
"size": part.get("body", {}).get("size", 0),
|
156
|
+
}
|
157
|
+
|
158
|
+
extract_attachments(payload, attachments)
|
159
|
+
|
160
|
+
return email_data, attachments
|
161
|
+
|
162
|
+
except Exception as e:
|
163
|
+
error_result = self.handle_api_error("get_email_with_attachments", e)
|
164
|
+
return error_result, {}
|
165
|
+
|
166
|
+
def create_draft(
|
167
|
+
self,
|
168
|
+
to: str,
|
169
|
+
subject: str,
|
170
|
+
body: str,
|
171
|
+
cc: list[str] | None = None,
|
172
|
+
bcc: list[str] | None = None,
|
173
|
+
) -> dict[str, Any] | None:
|
174
|
+
"""
|
175
|
+
Create a draft email message.
|
176
|
+
|
177
|
+
Args:
|
178
|
+
to: Email address of the recipient
|
179
|
+
subject: Subject line of the email
|
180
|
+
body: Body content of the email
|
181
|
+
cc: List of email addresses to CC
|
182
|
+
bcc: List of email addresses to BCC (note: BCC is not visible in drafts)
|
183
|
+
|
184
|
+
Returns:
|
185
|
+
Draft message data including the draft ID if successful
|
186
|
+
"""
|
187
|
+
try:
|
188
|
+
# Create the message in MIME format
|
189
|
+
mime_message = MIMEText(body)
|
190
|
+
mime_message["to"] = to
|
191
|
+
mime_message["subject"] = subject
|
192
|
+
|
193
|
+
if cc:
|
194
|
+
mime_message["cc"] = ",".join(cc)
|
195
|
+
|
196
|
+
# Note: BCC is typically not included in draft headers as it should remain hidden
|
197
|
+
# But we accept the parameter for API compatibility
|
198
|
+
if bcc:
|
199
|
+
# BCC recipients are usually handled at send time, not in draft creation
|
200
|
+
# For now, we accept the parameter but don't add it to headers
|
201
|
+
pass
|
202
|
+
|
203
|
+
# Encode the message
|
204
|
+
raw_message = base64.urlsafe_b64encode(mime_message.as_bytes()).decode("utf-8")
|
205
|
+
|
206
|
+
# Create the draft
|
207
|
+
return self.service.users().drafts().create(userId="me", body={"message": {"raw": raw_message}}).execute()
|
208
|
+
|
209
|
+
except Exception as e:
|
210
|
+
return self.handle_api_error("create_draft", e)
|
211
|
+
|
212
|
+
def delete_draft(self, draft_id: str) -> bool:
|
213
|
+
"""
|
214
|
+
Delete a draft email by its ID.
|
215
|
+
|
216
|
+
Args:
|
217
|
+
draft_id: The ID of the draft to delete
|
218
|
+
|
219
|
+
Returns:
|
220
|
+
True if draft was deleted successfully, False otherwise
|
221
|
+
"""
|
222
|
+
try:
|
223
|
+
self.service.users().drafts().delete(userId="me", id=draft_id).execute()
|
224
|
+
return True
|
225
|
+
|
226
|
+
except Exception as e:
|
227
|
+
logger.error(f"Error deleting draft {draft_id}: {e}")
|
228
|
+
return False
|
229
|
+
|
230
|
+
def send_draft(self, draft_id: str) -> dict[str, Any] | None:
|
231
|
+
"""
|
232
|
+
Sends a draft email message.
|
233
|
+
|
234
|
+
Args:
|
235
|
+
draft_id: The ID of the draft to send.
|
236
|
+
|
237
|
+
Returns:
|
238
|
+
The sent message object or an error dictionary.
|
239
|
+
"""
|
240
|
+
try:
|
241
|
+
logger.info(f"Sending draft with ID: {draft_id}")
|
242
|
+
|
243
|
+
# Send the draft - the Python client library handles this with the id parameter
|
244
|
+
message = self.service.users().drafts().send(userId="me", body={"id": draft_id}).execute()
|
245
|
+
|
246
|
+
logger.info(f"Successfully sent draft {draft_id}, new message ID: {message.get('id')}")
|
247
|
+
return message # Returns the sent Message resource
|
248
|
+
except Exception as e:
|
249
|
+
return self.handle_api_error("send_draft", e)
|
250
|
+
|
251
|
+
def send_email(
|
252
|
+
self,
|
253
|
+
to: list[str],
|
254
|
+
subject: str,
|
255
|
+
body: str,
|
256
|
+
cc: list[str] | None = None,
|
257
|
+
bcc: list[str] | None = None,
|
258
|
+
) -> dict[str, Any] | None:
|
259
|
+
"""
|
260
|
+
Composes and sends an email message directly.
|
261
|
+
|
262
|
+
Args:
|
263
|
+
to: List of email addresses of the primary recipients.
|
264
|
+
subject: Subject line of the email.
|
265
|
+
body: Body content of the email (plain text).
|
266
|
+
cc: Optional list of email addresses to CC.
|
267
|
+
bcc: Optional list of email addresses to BCC.
|
268
|
+
|
269
|
+
Returns:
|
270
|
+
The sent message object or an error dictionary.
|
271
|
+
"""
|
272
|
+
if not to:
|
273
|
+
logger.error("Recipient list 'to' cannot be empty for send_email.")
|
274
|
+
# Consistent error structure with handle_api_error, though not an API error directly
|
275
|
+
return {
|
276
|
+
"error": True,
|
277
|
+
"error_type": "validation_error",
|
278
|
+
"message": "Recipient list 'to' cannot be empty.",
|
279
|
+
"operation": "send_email",
|
280
|
+
}
|
281
|
+
|
282
|
+
try:
|
283
|
+
logger.info(f"Sending email to: {to}, subject: '{subject}'")
|
284
|
+
mime_message = MIMEText(body)
|
285
|
+
mime_message["To"] = ", ".join(to)
|
286
|
+
mime_message["Subject"] = subject
|
287
|
+
if cc:
|
288
|
+
mime_message["Cc"] = ", ".join(cc)
|
289
|
+
if bcc:
|
290
|
+
mime_message["Bcc"] = ", ".join(bcc)
|
291
|
+
|
292
|
+
# From address is implicitly the authenticated user.
|
293
|
+
# Gmail API usually sets the From header automatically based on the authenticated user.
|
294
|
+
|
295
|
+
encoded_message = base64.urlsafe_b64encode(mime_message.as_bytes()).decode()
|
296
|
+
message_body = {"raw": encoded_message}
|
297
|
+
|
298
|
+
message = self.service.users().messages().send(userId="me", body=message_body).execute()
|
299
|
+
logger.info(f"Successfully sent email, message ID: {message.get('id')}")
|
300
|
+
return message
|
301
|
+
except Exception as e:
|
302
|
+
return self.handle_api_error("send_email", e)
|
303
|
+
|
304
|
+
def create_reply(
|
305
|
+
self,
|
306
|
+
original_message: dict[str, Any],
|
307
|
+
reply_body: str,
|
308
|
+
send: bool = False,
|
309
|
+
cc: list[str] | None = None,
|
310
|
+
) -> dict[str, Any] | None:
|
311
|
+
"""
|
312
|
+
Create a reply to an email message and either send it or save as draft.
|
313
|
+
|
314
|
+
Args:
|
315
|
+
original_message: The original message data
|
316
|
+
reply_body: Body content of the reply
|
317
|
+
send: If True, sends the reply immediately. If False, saves as draft.
|
318
|
+
cc: List of email addresses to CC
|
319
|
+
|
320
|
+
Returns:
|
321
|
+
Sent message or draft data if successful
|
322
|
+
"""
|
323
|
+
try:
|
324
|
+
to_address = original_message.get("from")
|
325
|
+
if not to_address:
|
326
|
+
raise ValueError("Could not determine original sender's address")
|
327
|
+
|
328
|
+
subject = original_message.get("subject", "")
|
329
|
+
if not subject.lower().startswith("re:"):
|
330
|
+
subject = f"Re: {subject}"
|
331
|
+
|
332
|
+
# Format the reply with quoted original content
|
333
|
+
original_date = original_message.get("date", "")
|
334
|
+
original_from = original_message.get("from", "")
|
335
|
+
original_body = original_message.get("body", "")
|
336
|
+
|
337
|
+
# full_reply_body = (
|
338
|
+
# f"{reply_body}\n\n"
|
339
|
+
# f"On {original_date}, {original_from} wrote:\n"
|
340
|
+
# f"> {original_body.replace('\n', '\n> ') if original_body else '[No message body]'}"
|
341
|
+
# )
|
342
|
+
|
343
|
+
# First, prepare the quoted body text
|
344
|
+
quoted_body = original_body.replace("\n", "\n> ") if original_body else "[No message body]"
|
345
|
+
|
346
|
+
# Then use the prepared text in the f-string
|
347
|
+
full_reply_body = f"{reply_body}\n\nOn {original_date}, {original_from} wrote:\n> {quoted_body}"
|
348
|
+
|
349
|
+
# Create MIME message
|
350
|
+
mime_message = MIMEText(full_reply_body)
|
351
|
+
mime_message["to"] = to_address
|
352
|
+
mime_message["subject"] = subject
|
353
|
+
|
354
|
+
if cc:
|
355
|
+
mime_message["cc"] = ",".join(cc)
|
356
|
+
|
357
|
+
# Set reply headers
|
358
|
+
if "message_id" in original_message:
|
359
|
+
mime_message["In-Reply-To"] = original_message["message_id"]
|
360
|
+
mime_message["References"] = original_message["message_id"]
|
361
|
+
|
362
|
+
# Encode the message
|
363
|
+
raw_message = base64.urlsafe_b64encode(mime_message.as_bytes()).decode("utf-8")
|
364
|
+
|
365
|
+
message_body = {"raw": raw_message}
|
366
|
+
|
367
|
+
# Set thread ID if available
|
368
|
+
if "threadId" in original_message:
|
369
|
+
message_body["threadId"] = original_message["threadId"]
|
370
|
+
|
371
|
+
if send:
|
372
|
+
# Send the reply immediately
|
373
|
+
result = self.service.users().messages().send(userId="me", body=message_body).execute()
|
374
|
+
else:
|
375
|
+
# Save as draft
|
376
|
+
result = self.service.users().drafts().create(userId="me", body={"message": message_body}).execute()
|
377
|
+
|
378
|
+
return result
|
379
|
+
|
380
|
+
except Exception as e:
|
381
|
+
return self.handle_api_error("create_reply", e)
|
382
|
+
|
383
|
+
def reply_to_email(self, email_id: str, reply_body: str, reply_all: bool = False) -> dict[str, Any] | None:
|
384
|
+
"""
|
385
|
+
Reply to an email (wrapper for compatibility).
|
386
|
+
|
387
|
+
Args:
|
388
|
+
email_id: The ID of the email to reply to
|
389
|
+
reply_body: Body content of the reply
|
390
|
+
reply_all: If True, reply to all recipients
|
391
|
+
|
392
|
+
Returns:
|
393
|
+
Reply message data if successful
|
394
|
+
"""
|
395
|
+
try:
|
396
|
+
# Get the original message
|
397
|
+
original_message = self.get_email_by_id(email_id, parse_body=False)
|
398
|
+
if not original_message:
|
399
|
+
return {"error": True, "message": "Original email not found"}
|
400
|
+
|
401
|
+
# Use the existing create_reply method
|
402
|
+
cc = None
|
403
|
+
if reply_all:
|
404
|
+
# Extract CC recipients from original message
|
405
|
+
cc_header = original_message.get("cc")
|
406
|
+
if cc_header:
|
407
|
+
cc = [addr.strip() for addr in cc_header.split(",")]
|
408
|
+
|
409
|
+
return self.create_reply(
|
410
|
+
original_message=original_message,
|
411
|
+
reply_body=reply_body,
|
412
|
+
send=False, # Default to draft
|
413
|
+
cc=cc,
|
414
|
+
)
|
415
|
+
|
416
|
+
except Exception as e:
|
417
|
+
return self.handle_api_error("reply_to_email", e)
|
418
|
+
|
419
|
+
def get_attachment_content(self, message_id: str, attachment_id: str) -> dict[str, Any] | None:
|
420
|
+
"""
|
421
|
+
Get the content of an attachment from an email message.
|
422
|
+
|
423
|
+
Args:
|
424
|
+
message_id: The ID of the email message
|
425
|
+
attachment_id: The ID of the attachment
|
426
|
+
|
427
|
+
Returns:
|
428
|
+
Dictionary with attachment metadata and data
|
429
|
+
"""
|
430
|
+
try:
|
431
|
+
attachment = (
|
432
|
+
self.service.users()
|
433
|
+
.messages()
|
434
|
+
.attachments()
|
435
|
+
.get(userId="me", messageId=message_id, id=attachment_id)
|
436
|
+
.execute()
|
437
|
+
)
|
438
|
+
|
439
|
+
# Get the full message to extract metadata
|
440
|
+
message = self.service.users().messages().get(userId="me", id=message_id).execute()
|
441
|
+
attachment_info = self._find_attachment_in_payload(message.get("payload", {}), attachment_id)
|
442
|
+
|
443
|
+
return {
|
444
|
+
"data": attachment.get("data", ""),
|
445
|
+
"size": attachment.get("size", 0),
|
446
|
+
"filename": attachment_info.get("filename", "unknown"),
|
447
|
+
"mimeType": attachment_info.get("mimeType", "application/octet-stream"),
|
448
|
+
}
|
449
|
+
|
450
|
+
except Exception as e:
|
451
|
+
return self.handle_api_error("get_attachment_content", e)
|
452
|
+
|
453
|
+
def _find_attachment_in_payload(self, payload: dict[str, Any], attachment_id: str) -> dict[str, Any]:
|
454
|
+
"""
|
455
|
+
Find attachment information in the message payload.
|
456
|
+
|
457
|
+
Args:
|
458
|
+
payload: The message payload from Gmail API
|
459
|
+
attachment_id: The ID of the attachment to find
|
460
|
+
|
461
|
+
Returns:
|
462
|
+
Dictionary with attachment metadata (filename, mimeType)
|
463
|
+
"""
|
464
|
+
|
465
|
+
def search_parts(part):
|
466
|
+
if part.get("body", {}).get("attachmentId") == attachment_id:
|
467
|
+
return {
|
468
|
+
"filename": part.get("filename", "unknown"),
|
469
|
+
"mimeType": part.get("mimeType", "application/octet-stream"),
|
470
|
+
}
|
471
|
+
if "parts" in part:
|
472
|
+
for subpart in part["parts"]:
|
473
|
+
result = search_parts(subpart)
|
474
|
+
if result:
|
475
|
+
return result
|
476
|
+
return None
|
477
|
+
|
478
|
+
result = search_parts(payload)
|
479
|
+
return result or {"filename": "unknown", "mimeType": "application/octet-stream"}
|
480
|
+
|
481
|
+
def _parse_message(self, txt: dict[str, Any], parse_body: bool = False) -> dict[str, Any] | None:
|
482
|
+
"""
|
483
|
+
Parse a Gmail message into a structured format.
|
484
|
+
|
485
|
+
Args:
|
486
|
+
txt: Raw message from Gmail API
|
487
|
+
parse_body: Whether to parse and include the message body
|
488
|
+
|
489
|
+
Returns:
|
490
|
+
Parsed message dictionary
|
491
|
+
"""
|
492
|
+
try:
|
493
|
+
message_id = txt.get("id")
|
494
|
+
thread_id = txt.get("threadId")
|
495
|
+
payload = txt.get("payload", {})
|
496
|
+
headers = payload.get("headers", [])
|
497
|
+
|
498
|
+
metadata = {
|
499
|
+
"id": message_id,
|
500
|
+
"threadId": thread_id,
|
501
|
+
"historyId": txt.get("historyId"),
|
502
|
+
"internalDate": txt.get("internalDate"),
|
503
|
+
"sizeEstimate": txt.get("sizeEstimate"),
|
504
|
+
"labelIds": txt.get("labelIds", []),
|
505
|
+
"snippet": txt.get("snippet"),
|
506
|
+
}
|
507
|
+
|
508
|
+
# Extract headers
|
509
|
+
for header in headers:
|
510
|
+
name = header.get("name", "").lower()
|
511
|
+
value = header.get("value", "")
|
512
|
+
|
513
|
+
if name == "subject":
|
514
|
+
metadata["subject"] = value
|
515
|
+
elif name == "from":
|
516
|
+
metadata["from"] = value
|
517
|
+
elif name == "to":
|
518
|
+
metadata["to"] = value
|
519
|
+
elif name == "date":
|
520
|
+
metadata["date"] = value
|
521
|
+
elif name == "cc":
|
522
|
+
metadata["cc"] = value
|
523
|
+
elif name == "bcc":
|
524
|
+
metadata["bcc"] = value
|
525
|
+
elif name == "message-id":
|
526
|
+
metadata["message_id"] = value
|
527
|
+
elif name == "in-reply-to":
|
528
|
+
metadata["in_reply_to"] = value
|
529
|
+
elif name == "references":
|
530
|
+
metadata["references"] = value
|
531
|
+
elif name == "delivered-to":
|
532
|
+
metadata["delivered_to"] = value
|
533
|
+
|
534
|
+
# Parse body if requested
|
535
|
+
if parse_body:
|
536
|
+
body = self._extract_body(payload)
|
537
|
+
if body:
|
538
|
+
metadata["body"] = body
|
539
|
+
|
540
|
+
metadata["mimeType"] = payload.get("mimeType")
|
541
|
+
|
542
|
+
return metadata
|
543
|
+
|
544
|
+
except Exception as e:
|
545
|
+
logger.error(f"Error parsing message: {str(e)}")
|
546
|
+
logger.error(traceback.format_exc())
|
547
|
+
return None
|
548
|
+
|
549
|
+
def _extract_body(self, payload: dict[str, Any]) -> str | None:
|
550
|
+
"""
|
551
|
+
Extract the email body from the payload.
|
552
|
+
|
553
|
+
Args:
|
554
|
+
payload: The message payload from Gmail API
|
555
|
+
|
556
|
+
Returns:
|
557
|
+
Extracted body text or None if extraction fails
|
558
|
+
"""
|
559
|
+
try:
|
560
|
+
# For single part text/plain messages
|
561
|
+
if payload.get("mimeType") == "text/plain":
|
562
|
+
data = payload.get("body", {}).get("data")
|
563
|
+
if data:
|
564
|
+
return base64.urlsafe_b64decode(data).decode("utf-8")
|
565
|
+
|
566
|
+
# For multipart messages
|
567
|
+
if payload.get("mimeType", "").startswith("multipart/"):
|
568
|
+
parts = payload.get("parts", [])
|
569
|
+
|
570
|
+
# First try to find a direct text/plain part
|
571
|
+
for part in parts:
|
572
|
+
if part.get("mimeType") == "text/plain":
|
573
|
+
data = part.get("body", {}).get("data")
|
574
|
+
if data:
|
575
|
+
return base64.urlsafe_b64decode(data).decode("utf-8")
|
576
|
+
|
577
|
+
# If no direct text/plain, recursively check nested multipart structures
|
578
|
+
for part in parts:
|
579
|
+
if part.get("mimeType", "").startswith("multipart/"):
|
580
|
+
nested_body = self._extract_body(part)
|
581
|
+
if nested_body:
|
582
|
+
return nested_body
|
583
|
+
|
584
|
+
# If still no body found, try the first part as fallback
|
585
|
+
if parts and "body" in parts[0] and "data" in parts[0]["body"]:
|
586
|
+
data = parts[0]["body"]["data"]
|
587
|
+
return base64.urlsafe_b64decode(data).decode("utf-8")
|
588
|
+
|
589
|
+
return None
|
590
|
+
|
591
|
+
except Exception as e:
|
592
|
+
logger.error(f"Error extracting body: {str(e)}")
|
593
|
+
return None
|
594
|
+
|
595
|
+
def bulk_delete_messages(self, message_ids: list[str]) -> dict[str, Any]:
|
596
|
+
"""
|
597
|
+
Delete multiple messages by their IDs using batch delete.
|
598
|
+
|
599
|
+
Args:
|
600
|
+
message_ids: List of message IDs to delete
|
601
|
+
|
602
|
+
Returns:
|
603
|
+
Dictionary with operation result
|
604
|
+
"""
|
605
|
+
if not message_ids:
|
606
|
+
return {"success": False, "message": "No message IDs provided"}
|
607
|
+
|
608
|
+
# Validate message IDs
|
609
|
+
if not all(isinstance(msg_id, str) and msg_id.strip() for msg_id in message_ids):
|
610
|
+
return {
|
611
|
+
"success": False,
|
612
|
+
"message": "Invalid message IDs - all IDs must be non-empty strings",
|
613
|
+
}
|
614
|
+
|
615
|
+
try:
|
616
|
+
# The batchDelete endpoint has a limit of how many IDs it can process at once
|
617
|
+
max_batch_size = 1000 # Gmail API max batch size
|
618
|
+
results = []
|
619
|
+
total_count = 0
|
620
|
+
|
621
|
+
# Process in batches with rate limiting
|
622
|
+
for i in range(0, len(message_ids), max_batch_size):
|
623
|
+
if i > 0:
|
624
|
+
# Add a small delay between batches to avoid rate limiting
|
625
|
+
time.sleep(0.5)
|
626
|
+
|
627
|
+
batch = message_ids[i : i + max_batch_size]
|
628
|
+
|
629
|
+
self.service.users().messages().batchDelete(userId="me", body={"ids": batch}).execute()
|
630
|
+
|
631
|
+
batch_count = len(batch)
|
632
|
+
total_count += batch_count
|
633
|
+
results.append({"count": batch_count, "success": True})
|
634
|
+
|
635
|
+
return {
|
636
|
+
"success": True,
|
637
|
+
"message": f"Batch delete request for {total_count} message(s) sent successfully. Deletion may take a moment to reflect.",
|
638
|
+
"count_requested": total_count,
|
639
|
+
}
|
640
|
+
except Exception as e:
|
641
|
+
return self.handle_api_error("bulk_delete_messages", e)
|
642
|
+
|
643
|
+
def get_unread_count(self) -> int:
|
644
|
+
"""
|
645
|
+
Get count of unread emails in the inbox.
|
646
|
+
|
647
|
+
Returns:
|
648
|
+
The count of unread emails
|
649
|
+
"""
|
650
|
+
try:
|
651
|
+
# Query for unread emails in inbox
|
652
|
+
query = "is:unread in:inbox"
|
653
|
+
results = (
|
654
|
+
self.service.users()
|
655
|
+
.messages()
|
656
|
+
.list(
|
657
|
+
userId="me",
|
658
|
+
q=query,
|
659
|
+
maxResults=1, # We only need the count, not the actual messages
|
660
|
+
)
|
661
|
+
.execute()
|
662
|
+
)
|
663
|
+
|
664
|
+
# Get the total count
|
665
|
+
return results.get("resultSizeEstimate", 0)
|
666
|
+
|
667
|
+
except Exception as e:
|
668
|
+
return self.handle_api_error("get_unread_count", e)
|
669
|
+
|
670
|
+
def get_labels(self) -> list[dict[str, Any]]:
|
671
|
+
"""Get all Gmail labels for the authenticated user."""
|
672
|
+
try:
|
673
|
+
results = self.service.users().labels().list(userId="me").execute()
|
674
|
+
return results.get("labels", [])
|
675
|
+
except Exception as e:
|
676
|
+
return self.handle_api_error("get_labels", e)
|