universal-mcp 0.1.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.
@@ -0,0 +1,565 @@
1
+ from universal_mcp.applications.application import APIApplication
2
+ from universal_mcp.integrations import Integration
3
+ from universal_mcp.exceptions import NotAuthorizedError
4
+ from loguru import logger
5
+ import base64
6
+ from email.message import EmailMessage
7
+
8
+ class GmailApp(APIApplication):
9
+ def __init__(self, integration: Integration) -> None:
10
+ super().__init__(name="gmail", integration=integration)
11
+ self.base_api_url = "https://gmail.googleapis.com/gmail/v1/users/me"
12
+
13
+ def _get_headers(self):
14
+ if not self.integration:
15
+ raise ValueError("Integration not configured for GmailApp")
16
+ credentials = self.integration.get_credentials()
17
+ if not credentials:
18
+ logger.warning("No Gmail credentials found via integration.")
19
+ action = self.integration.authorize()
20
+ raise NotAuthorizedError(action)
21
+
22
+ if "headers" in credentials:
23
+ return credentials["headers"]
24
+ return {
25
+ "Authorization": f"Bearer {credentials['access_token']}",
26
+ 'Content-Type': 'application/json'
27
+ }
28
+
29
+ def send_email(self, to: str, subject: str, body: str) -> str:
30
+ """Send an email
31
+
32
+ Args:
33
+ to: The email address of the recipient
34
+ subject: The subject of the email
35
+ body: The body of the email
36
+
37
+ Returns:
38
+ A confirmation message
39
+ """
40
+ try:
41
+ url = f"{self.base_api_url}/messages/send"
42
+
43
+ # Create email in base64 encoded format
44
+ raw_message = self._create_message(to, subject, body)
45
+
46
+ # Format the data as expected by Gmail API
47
+ email_data = {
48
+ "raw": raw_message
49
+ }
50
+
51
+ logger.info(f"Sending email to {to}")
52
+
53
+ response = self._post(url, email_data)
54
+
55
+ if response.status_code == 200:
56
+ return f"Successfully sent email to {to}"
57
+ else:
58
+ logger.error(f"Gmail API Error: {response.status_code} - {response.text}")
59
+ return f"Error sending email: {response.status_code} - {response.text}"
60
+ except NotAuthorizedError as e:
61
+ # Return the authorization message directly
62
+ logger.warning(f"Gmail authorization required: {e.message}")
63
+ return e.message
64
+ except KeyError as key_error:
65
+ logger.error(f"Missing key error: {str(key_error)}")
66
+ return f"Configuration error: Missing required key - {str(key_error)}"
67
+ except Exception as e:
68
+ logger.exception(f"Error sending email: {type(e).__name__} - {str(e)}")
69
+ return f"Error sending email: {type(e).__name__} - {str(e)}"
70
+
71
+ def _create_message(self, to, subject, body):
72
+ try:
73
+ message = EmailMessage()
74
+ message['to'] = to
75
+ message['subject'] = subject
76
+ message.set_content(body)
77
+
78
+ # Use "me" as the default sender
79
+ message['from'] = "me"
80
+
81
+ # Encode as base64 string
82
+ raw = base64.urlsafe_b64encode(message.as_bytes()).decode()
83
+ return raw
84
+ except Exception as e:
85
+ logger.error(f"Error creating message: {str(e)}")
86
+ raise
87
+
88
+ def create_draft(self, to: str, subject: str, body: str) -> str:
89
+ """Create a draft email
90
+
91
+ Args:
92
+ to: The email address of the recipient
93
+ subject: The subject of the email
94
+ body: The body of the email
95
+
96
+ Returns:
97
+ A confirmation message with the draft ID
98
+ """
99
+ try:
100
+ url = f"{self.base_api_url}/drafts"
101
+
102
+ raw_message = self._create_message(to, subject, body)
103
+
104
+ draft_data = {
105
+ "message": {
106
+ "raw": raw_message
107
+ }
108
+ }
109
+
110
+ logger.info(f"Creating draft email to {to}")
111
+
112
+ response = self._post(url, draft_data)
113
+
114
+ if response.status_code == 200:
115
+ draft_id = response.json().get("id", "unknown")
116
+ return f"Successfully created draft email with ID: {draft_id}"
117
+ else:
118
+ logger.error(f"Gmail API Error: {response.status_code} - {response.text}")
119
+ return f"Error creating draft: {response.status_code} - {response.text}"
120
+ except NotAuthorizedError as e:
121
+ logger.warning(f"Gmail authorization required: {e.message}")
122
+ return e.message
123
+ except KeyError as key_error:
124
+ logger.error(f"Missing key error: {str(key_error)}")
125
+ return f"Configuration error: Missing required key - {str(key_error)}"
126
+ except Exception as e:
127
+ logger.exception(f"Error creating draft: {type(e).__name__} - {str(e)}")
128
+ return f"Error creating draft: {type(e).__name__} - {str(e)}"
129
+
130
+ def send_draft(self, draft_id: str) -> str:
131
+ """Send an existing draft email
132
+
133
+ Args:
134
+ draft_id: The ID of the draft to send
135
+
136
+ Returns:
137
+ A confirmation message
138
+ """
139
+ try:
140
+ url = f"{self.base_api_url}/drafts/send"
141
+
142
+ draft_data = {
143
+ "id": draft_id
144
+ }
145
+
146
+ logger.info(f"Sending draft email with ID: {draft_id}")
147
+
148
+ response = self._post(url, draft_data)
149
+
150
+ if response.status_code == 200:
151
+ message_id = response.json().get("id", "unknown")
152
+ return f"Successfully sent draft email. Message ID: {message_id}"
153
+ else:
154
+ logger.error(f"Gmail API Error: {response.status_code} - {response.text}")
155
+ return f"Error sending draft: {response.status_code} - {response.text}"
156
+ except NotAuthorizedError as e:
157
+ logger.warning(f"Gmail authorization required: {e.message}")
158
+ return e.message
159
+ except KeyError as key_error:
160
+ logger.error(f"Missing key error: {str(key_error)}")
161
+ return f"Configuration error: Missing required key - {str(key_error)}"
162
+ except Exception as e:
163
+ logger.exception(f"Error sending draft: {type(e).__name__} - {str(e)}")
164
+ return f"Error sending draft: {type(e).__name__} - {str(e)}"
165
+
166
+ def get_draft(self, draft_id: str, format: str = "full") -> str:
167
+ """Get a specific draft email by ID
168
+
169
+ Args:
170
+ draft_id: The ID of the draft to retrieve
171
+ format: The format to return the draft in (minimal, full, raw, metadata)
172
+
173
+ Returns:
174
+ The draft information or an error message
175
+ """
176
+ try:
177
+ url = f"{self.base_api_url}/drafts/{draft_id}"
178
+
179
+ # Add format parameter as query param
180
+ params = {"format": format}
181
+
182
+ logger.info(f"Retrieving draft with ID: {draft_id}")
183
+
184
+ response = self._get(url, params=params)
185
+
186
+ if response.status_code == 200:
187
+ draft_data = response.json()
188
+
189
+ # Format the response in a readable way
190
+ message = draft_data.get("message", {})
191
+ headers = {}
192
+
193
+ # Extract headers if they exist
194
+ for header in message.get("payload", {}).get("headers", []):
195
+ name = header.get("name", "")
196
+ value = header.get("value", "")
197
+ headers[name] = value
198
+
199
+ to = headers.get("To", "Unknown recipient")
200
+ subject = headers.get("Subject", "No subject")
201
+
202
+ result = (
203
+ f"Draft ID: {draft_id}\n"
204
+ f"To: {to}\n"
205
+ f"Subject: {subject}\n"
206
+ )
207
+
208
+ return result
209
+ else:
210
+ logger.error(f"Gmail API Error: {response.status_code} - {response.text}")
211
+ return f"Error retrieving draft: {response.status_code} - {response.text}"
212
+ except NotAuthorizedError as e:
213
+ logger.warning(f"Gmail authorization required: {e.message}")
214
+ return e.message
215
+ except KeyError as key_error:
216
+ logger.error(f"Missing key error: {str(key_error)}")
217
+ return f"Configuration error: Missing required key - {str(key_error)}"
218
+ except Exception as e:
219
+ logger.exception(f"Error retrieving draft: {type(e).__name__} - {str(e)}")
220
+ return f"Error retrieving draft: {type(e).__name__} - {str(e)}"
221
+
222
+ def list_drafts(self, max_results: int = 20, q: str = None, include_spam_trash: bool = False) -> str:
223
+ """List drafts in the user's mailbox
224
+
225
+ Args:
226
+ max_results: Maximum number of drafts to return (max 500, default 20)
227
+ q: Search query to filter drafts (same format as Gmail search)
228
+ include_spam_trash: Include drafts from spam and trash folders
229
+
230
+ Returns:
231
+ A formatted list of drafts or an error message
232
+ """
233
+ try:
234
+ url = f"{self.base_api_url}/drafts"
235
+
236
+ # Build query parameters
237
+ params = {
238
+ "maxResults": max_results
239
+ }
240
+
241
+ if q:
242
+ params["q"] = q
243
+
244
+ if include_spam_trash:
245
+ params["includeSpamTrash"] = "true"
246
+
247
+ logger.info(f"Retrieving drafts list with params: {params}")
248
+
249
+ response = self._get(url, params=params)
250
+
251
+ if response.status_code == 200:
252
+ data = response.json()
253
+ drafts = data.get("drafts", [])
254
+ result_size = data.get("resultSizeEstimate", 0)
255
+
256
+ if not drafts:
257
+ return "No drafts found."
258
+
259
+ result = f"Found {len(drafts)} drafts (estimated total: {result_size}):\n\n"
260
+
261
+ for i, draft in enumerate(drafts, 1):
262
+ draft_id = draft.get("id", "Unknown ID")
263
+ # The message field only contains id and threadId at this level
264
+ result += f"{i}. Draft ID: {draft_id}\n"
265
+
266
+ if "nextPageToken" in data:
267
+ result += "\nMore drafts available. Use page token to see more."
268
+
269
+ return result
270
+ else:
271
+ logger.error(f"Gmail API Error: {response.status_code} - {response.text}")
272
+ return f"Error listing drafts: {response.status_code} - {response.text}"
273
+ except NotAuthorizedError as e:
274
+ logger.warning(f"Gmail authorization required: {e.message}")
275
+ return e.message
276
+ except KeyError as key_error:
277
+ logger.error(f"Missing key error: {str(key_error)}")
278
+ return f"Configuration error: Missing required key - {str(key_error)}"
279
+ except Exception as e:
280
+ logger.exception(f"Error listing drafts: {type(e).__name__} - {str(e)}")
281
+ return f"Error listing drafts: {type(e).__name__} - {str(e)}"
282
+
283
+ def get_message(self, message_id: str) -> str:
284
+ """Get a specific email message by ID
285
+
286
+ Args:
287
+ message_id: The ID of the message to retrieve
288
+
289
+ Returns:
290
+ The message information or an error message
291
+ """
292
+ try:
293
+ url = f"{self.base_api_url}/messages/{message_id}"
294
+
295
+ logger.info(f"Retrieving message with ID: {message_id}")
296
+
297
+ response = self._get(url)
298
+
299
+ if response.status_code == 200:
300
+ message_data = response.json()
301
+
302
+ # Extract basic message metadata
303
+ headers = {}
304
+
305
+ # Extract headers if they exist
306
+ for header in message_data.get("payload", {}).get("headers", []):
307
+ name = header.get("name", "")
308
+ value = header.get("value", "")
309
+ headers[name] = value
310
+
311
+ from_addr = headers.get("From", "Unknown sender")
312
+ to = headers.get("To", "Unknown recipient")
313
+ subject = headers.get("Subject", "No subject")
314
+ date = headers.get("Date", "Unknown date")
315
+
316
+ # Format the result
317
+ result = (
318
+ f"Message ID: {message_id}\n"
319
+ f"From: {from_addr}\n"
320
+ f"To: {to}\n"
321
+ f"Date: {date}\n"
322
+ f"Subject: {subject}\n\n"
323
+ )
324
+
325
+ # Include snippet as preview of message content
326
+ if "snippet" in message_data:
327
+ result += f"Preview: {message_data['snippet']}\n"
328
+
329
+ return result
330
+ else:
331
+ logger.error(f"Gmail API Error: {response.status_code} - {response.text}")
332
+ return f"Error retrieving message: {response.status_code} - {response.text}"
333
+ except NotAuthorizedError as e:
334
+ logger.warning(f"Gmail authorization required: {e.message}")
335
+ return e.message
336
+ except KeyError as key_error:
337
+ logger.error(f"Missing key error: {str(key_error)}")
338
+ return f"Configuration error: Missing required key - {str(key_error)}"
339
+ except Exception as e:
340
+ logger.exception(f"Error retrieving message: {type(e).__name__} - {str(e)}")
341
+ return f"Error retrieving message: {type(e).__name__} - {str(e)}"
342
+
343
+ def list_messages(self, max_results: int = 20, q: str = None, include_spam_trash: bool = False) -> str:
344
+ """List messages in the user's mailbox
345
+
346
+ Args:
347
+ max_results: Maximum number of messages to return (max 500, default 20)
348
+ q: Search query to filter messages (same format as Gmail search)
349
+ include_spam_trash: Include messages from spam and trash folders
350
+
351
+ Returns:
352
+ A formatted list of messages or an error message
353
+ """
354
+ try:
355
+ url = f"{self.base_api_url}/messages"
356
+
357
+ # Build query parameters
358
+ params = {
359
+ "maxResults": max_results
360
+ }
361
+
362
+ if q:
363
+ params["q"] = q
364
+
365
+ if include_spam_trash:
366
+ params["includeSpamTrash"] = "true"
367
+
368
+ logger.info(f"Retrieving messages list with params: {params}")
369
+
370
+ response = self._get(url, params=params)
371
+
372
+ if response.status_code == 200:
373
+ data = response.json()
374
+ messages = data.get("messages", [])
375
+ result_size = data.get("resultSizeEstimate", 0)
376
+
377
+ if not messages:
378
+ return "No messages found matching the criteria."
379
+
380
+ result = f"Found {len(messages)} messages (estimated total: {result_size}):\n\n"
381
+
382
+ # Just list message IDs without fetching additional details
383
+ for i, msg in enumerate(messages, 1):
384
+ message_id = msg.get("id", "Unknown ID")
385
+ thread_id = msg.get("threadId", "Unknown Thread")
386
+ result += f"{i}. Message ID: {message_id} (Thread: {thread_id})\n"
387
+
388
+ # Add a note about how to get message details
389
+ result += "\nUse get_message(message_id) to view the contents of a specific message."
390
+
391
+ if "nextPageToken" in data:
392
+ result += "\nMore messages available. Use page token to see more."
393
+
394
+ return result
395
+ else:
396
+ logger.error(f"Gmail API Error: {response.status_code} - {response.text}")
397
+ return f"Error listing messages: {response.status_code} - {response.text}"
398
+ except NotAuthorizedError as e:
399
+ logger.warning(f"Gmail authorization required: {e.message}")
400
+ return e.message
401
+ except KeyError as key_error:
402
+ logger.error(f"Missing key error: {str(key_error)}")
403
+ return f"Configuration error: Missing required key - {str(key_error)}"
404
+ except Exception as e:
405
+ logger.exception(f"Error listing messages: {type(e).__name__} - {str(e)}")
406
+ return f"Error listing messages: {type(e).__name__} - {str(e)}"
407
+
408
+
409
+
410
+ def list_labels(self) -> str:
411
+ """List all labels in the user's Gmail account
412
+
413
+ Returns:
414
+ A formatted list of labels or an error message
415
+ """
416
+ try:
417
+ url = f"{self.base_api_url}/labels"
418
+
419
+ logger.info("Retrieving Gmail labels")
420
+
421
+ response = self._get(url)
422
+
423
+ if response.status_code == 200:
424
+ data = response.json()
425
+ labels = data.get("labels", [])
426
+
427
+ if not labels:
428
+ return "No labels found in your Gmail account."
429
+
430
+ # Sort labels by type (system first, then user) and then by name
431
+ system_labels = []
432
+ user_labels = []
433
+
434
+ for label in labels:
435
+ label_id = label.get("id", "Unknown ID")
436
+ label_name = label.get("name", "Unknown Name")
437
+ label_type = label.get("type", "Unknown Type")
438
+
439
+ if label_type == "system":
440
+ system_labels.append((label_id, label_name))
441
+ else:
442
+ user_labels.append((label_id, label_name))
443
+
444
+ # Sort by name within each category
445
+ system_labels.sort(key=lambda x: x[1])
446
+ user_labels.sort(key=lambda x: x[1])
447
+
448
+ result = f"Found {len(labels)} Gmail labels:\n\n"
449
+
450
+ # System labels
451
+ if system_labels:
452
+ result += "System Labels:\n"
453
+ for label_id, label_name in system_labels:
454
+ result += f"- {label_name} (ID: {label_id})\n"
455
+ result += "\n"
456
+
457
+ # User labels
458
+ if user_labels:
459
+ result += "User Labels:\n"
460
+ for label_id, label_name in user_labels:
461
+ result += f"- {label_name} (ID: {label_id})\n"
462
+
463
+ # Add note about using labels
464
+ result += "\nThese label IDs can be used with list_messages to filter emails by label."
465
+
466
+ return result
467
+ else:
468
+ logger.error(f"Gmail API Error: {response.status_code} - {response.text}")
469
+ return f"Error listing labels: {response.status_code} - {response.text}"
470
+ except NotAuthorizedError as e:
471
+ logger.warning(f"Gmail authorization required: {e.message}")
472
+ return e.message
473
+ except Exception as e:
474
+ logger.exception(f"Error listing labels: {type(e).__name__} - {str(e)}")
475
+ return f"Error listing labels: {type(e).__name__} - {str(e)}"
476
+
477
+ def create_label(self, name: str) -> str:
478
+ """Create a new Gmail label
479
+
480
+ Args:
481
+ name: The display name of the label to create
482
+
483
+ Returns:
484
+ A confirmation message with the new label details
485
+ """
486
+ try:
487
+ url = f"{self.base_api_url}/labels"
488
+
489
+ # Create the label data with just the essential fields
490
+ label_data = {
491
+ "name": name,
492
+ "labelListVisibility": "labelShow", # Show in label list
493
+ "messageListVisibility": "show" # Show in message list
494
+ }
495
+
496
+ logger.info(f"Creating new Gmail label: {name}")
497
+
498
+ response = self._post(url, label_data)
499
+
500
+ if response.status_code in [200, 201]:
501
+ new_label = response.json()
502
+ label_id = new_label.get("id", "Unknown")
503
+ label_name = new_label.get("name", name)
504
+
505
+ result = f"Successfully created new label:\n"
506
+ result += f"- Name: {label_name}\n"
507
+ result += f"- ID: {label_id}\n"
508
+
509
+ return result
510
+ else:
511
+ logger.error(f"Gmail API Error: {response.status_code} - {response.text}")
512
+ return f"Error creating label: {response.status_code} - {response.text}"
513
+ except NotAuthorizedError as e:
514
+ logger.warning(f"Gmail authorization required: {e.message}")
515
+ return e.message
516
+ except Exception as e:
517
+ logger.exception(f"Error creating label: {type(e).__name__} - {str(e)}")
518
+ return f"Error creating label: {type(e).__name__} - {str(e)}"
519
+ def get_profile(self) -> str:
520
+ """Retrieve the user's Gmail profile information.
521
+
522
+ This method fetches the user's email address, message count, thread count,
523
+ and current history ID from the Gmail API.
524
+
525
+ Returns:
526
+ A formatted string containing the user's profile information or an error message
527
+ """
528
+ try:
529
+ url = f"{self.base_api_url}/profile"
530
+
531
+ logger.info("Retrieving Gmail user profile")
532
+
533
+ response = self._get(url)
534
+
535
+ if response.status_code == 200:
536
+ profile_data = response.json()
537
+
538
+ # Extract profile information
539
+ email_address = profile_data.get("emailAddress", "Unknown")
540
+ messages_total = profile_data.get("messagesTotal", 0)
541
+ threads_total = profile_data.get("threadsTotal", 0)
542
+ history_id = profile_data.get("historyId", "Unknown")
543
+
544
+ # Format the response
545
+ result = "Gmail Profile Information:\n"
546
+ result += f"- Email Address: {email_address}\n"
547
+ result += f"- Total Messages: {messages_total:,}\n"
548
+ result += f"- Total Threads: {threads_total:,}\n"
549
+ result += f"- History ID: {history_id}\n"
550
+
551
+ return result
552
+ else:
553
+ logger.error(f"Gmail API Error: {response.status_code} - {response.text}")
554
+ return f"Error retrieving profile: {response.status_code} - {response.text}"
555
+ except NotAuthorizedError as e:
556
+ logger.warning(f"Gmail authorization required: {e.message}")
557
+ return e.message
558
+ except Exception as e:
559
+ logger.exception(f"Error retrieving profile: {type(e).__name__} - {str(e)}")
560
+ return f"Error retrieving profile: {type(e).__name__} - {str(e)}"
561
+
562
+ def list_tools(self):
563
+ return [self.send_email, self.create_draft, self.send_draft, self.get_draft,
564
+ self.list_drafts, self.get_message, self.list_messages,
565
+ self.list_labels, self.create_label, self.get_profile]