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.
Files changed (38) hide show
  1. google_workspace_mcp/__init__.py +3 -0
  2. google_workspace_mcp/__main__.py +43 -0
  3. google_workspace_mcp/app.py +8 -0
  4. google_workspace_mcp/auth/__init__.py +7 -0
  5. google_workspace_mcp/auth/gauth.py +62 -0
  6. google_workspace_mcp/config.py +60 -0
  7. google_workspace_mcp/prompts/__init__.py +3 -0
  8. google_workspace_mcp/prompts/calendar.py +36 -0
  9. google_workspace_mcp/prompts/drive.py +18 -0
  10. google_workspace_mcp/prompts/gmail.py +65 -0
  11. google_workspace_mcp/prompts/slides.py +40 -0
  12. google_workspace_mcp/resources/__init__.py +13 -0
  13. google_workspace_mcp/resources/calendar.py +79 -0
  14. google_workspace_mcp/resources/drive.py +93 -0
  15. google_workspace_mcp/resources/gmail.py +58 -0
  16. google_workspace_mcp/resources/sheets_resources.py +92 -0
  17. google_workspace_mcp/resources/slides.py +421 -0
  18. google_workspace_mcp/services/__init__.py +21 -0
  19. google_workspace_mcp/services/base.py +73 -0
  20. google_workspace_mcp/services/calendar.py +256 -0
  21. google_workspace_mcp/services/docs_service.py +388 -0
  22. google_workspace_mcp/services/drive.py +454 -0
  23. google_workspace_mcp/services/gmail.py +676 -0
  24. google_workspace_mcp/services/sheets_service.py +466 -0
  25. google_workspace_mcp/services/slides.py +959 -0
  26. google_workspace_mcp/tools/__init__.py +7 -0
  27. google_workspace_mcp/tools/calendar.py +229 -0
  28. google_workspace_mcp/tools/docs_tools.py +277 -0
  29. google_workspace_mcp/tools/drive.py +221 -0
  30. google_workspace_mcp/tools/gmail.py +344 -0
  31. google_workspace_mcp/tools/sheets_tools.py +322 -0
  32. google_workspace_mcp/tools/slides.py +478 -0
  33. google_workspace_mcp/utils/__init__.py +1 -0
  34. google_workspace_mcp/utils/markdown_slides.py +504 -0
  35. google_workspace_mcp-1.0.0.dist-info/METADATA +547 -0
  36. google_workspace_mcp-1.0.0.dist-info/RECORD +38 -0
  37. google_workspace_mcp-1.0.0.dist-info/WHEEL +4 -0
  38. 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)