arcade-google 0.1.6__py3-none-any.whl → 2.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.
@@ -1,24 +1,30 @@
1
1
  import base64
2
- from email.message import EmailMessage
3
2
  from email.mime.text import MIMEText
4
- from typing import Annotated, Optional
3
+ from typing import Annotated, Any
5
4
 
6
- from google.oauth2.credentials import Credentials
7
- from googleapiclient.discovery import build
5
+ from arcade_tdk import ToolContext, tool
6
+ from arcade_tdk.auth import Google
7
+ from arcade_tdk.errors import RetryableToolError
8
8
  from googleapiclient.errors import HttpError
9
9
 
10
- from arcade.sdk import ToolContext, tool
11
- from arcade.sdk.auth import Google
12
- from arcade.sdk.errors import RetryableToolError
13
- from arcade_google.tools.utils import (
10
+ from arcade_google.constants import GMAIL_DEFAULT_REPLY_TO
11
+ from arcade_google.exceptions import GmailToolError
12
+ from arcade_google.models import GmailAction, GmailReplyToWhom
13
+ from arcade_google.utils import (
14
14
  DateRange,
15
- build_query_string,
15
+ _build_gmail_service,
16
+ build_email_message,
17
+ build_gmail_query_string,
18
+ build_reply_recipients,
16
19
  fetch_messages,
17
20
  get_draft_url,
21
+ get_email_details,
18
22
  get_email_in_trash_url,
23
+ get_label_ids,
19
24
  get_sent_email_url,
20
25
  parse_draft_email,
21
- parse_email,
26
+ parse_multipart_email,
27
+ parse_plain_text_email,
22
28
  remove_none_values,
23
29
  )
24
30
 
@@ -34,35 +40,18 @@ async def send_email(
34
40
  subject: Annotated[str, "The subject of the email"],
35
41
  body: Annotated[str, "The body of the email"],
36
42
  recipient: Annotated[str, "The recipient of the email"],
37
- cc: Annotated[Optional[list[str]], "CC recipients of the email"] = None,
38
- bcc: Annotated[Optional[list[str]], "BCC recipients of the email"] = None,
43
+ cc: Annotated[list[str] | None, "CC recipients of the email"] = None,
44
+ bcc: Annotated[list[str] | None, "BCC recipients of the email"] = None,
39
45
  ) -> Annotated[dict, "A dictionary containing the sent email details"]:
40
46
  """
41
47
  Send an email using the Gmail API.
42
48
  """
49
+ service = _build_gmail_service(context)
50
+ email = build_email_message(recipient, subject, body, cc, bcc)
43
51
 
44
- # Set up the Gmail API client
45
- service = build("gmail", "v1", credentials=Credentials(context.authorization.token))
46
-
47
- message = EmailMessage()
48
- message.set_content(body)
49
- message["To"] = recipient
50
- message["Subject"] = subject
51
- if cc:
52
- message["Cc"] = ", ".join(cc)
53
- if bcc:
54
- message["Bcc"] = ", ".join(bcc)
55
-
56
- # Encode the message in base64
57
- encoded_message = base64.urlsafe_b64encode(message.as_bytes()).decode()
58
-
59
- # Create the email
60
- email = {"raw": encoded_message}
61
-
62
- # Send the email
63
52
  sent_message = service.users().messages().send(userId="me", body=email).execute()
64
53
 
65
- email = parse_email(sent_message)
54
+ email = parse_plain_text_email(sent_message)
66
55
  email["url"] = get_sent_email_url(sent_message["id"])
67
56
  return email
68
57
 
@@ -79,13 +68,79 @@ async def send_draft_email(
79
68
  Send a draft email using the Gmail API.
80
69
  """
81
70
 
82
- # Set up the Gmail API client
83
- service = build("gmail", "v1", credentials=Credentials(context.authorization.token))
71
+ service = _build_gmail_service(context)
84
72
 
85
73
  # Send the draft email
86
74
  sent_message = service.users().drafts().send(userId="me", body={"id": email_id}).execute()
87
75
 
88
- email = parse_email(sent_message)
76
+ email = parse_plain_text_email(sent_message)
77
+ email["url"] = get_sent_email_url(sent_message["id"])
78
+ return email
79
+
80
+
81
+ # Note: in the Gmail UI, a user can customize the recipient and cc fields before replying.
82
+ # We decided not to support this feature, since we'd need a way for LLMs to tell apart between
83
+ # adding or removing recipients/cc, or replacing with an entirely new list of addresses,
84
+ # which would make the tool more complex to call.
85
+ @tool(
86
+ requires_auth=Google(
87
+ scopes=["https://www.googleapis.com/auth/gmail.send"],
88
+ )
89
+ )
90
+ async def reply_to_email(
91
+ context: ToolContext,
92
+ body: Annotated[str, "The body of the email"],
93
+ reply_to_message_id: Annotated[str, "The ID of the message to reply to"],
94
+ reply_to_whom: Annotated[
95
+ GmailReplyToWhom,
96
+ "Whether to reply to every recipient (including cc) or only to the original sender. "
97
+ f"Defaults to '{GMAIL_DEFAULT_REPLY_TO}'.",
98
+ ] = GMAIL_DEFAULT_REPLY_TO,
99
+ bcc: Annotated[list[str] | None, "BCC recipients of the email"] = None,
100
+ ) -> Annotated[dict, "A dictionary containing the sent email details"]:
101
+ """
102
+ Send a reply to an email message.
103
+ """
104
+ if isinstance(reply_to_whom, str):
105
+ reply_to_whom = GmailReplyToWhom(reply_to_whom)
106
+
107
+ service = _build_gmail_service(context)
108
+
109
+ current_user = service.users().getProfile(userId="me").execute()
110
+
111
+ try:
112
+ replying_to_email = (
113
+ service.users().messages().get(userId="me", id=reply_to_message_id).execute()
114
+ )
115
+ except HttpError as e:
116
+ raise RetryableToolError(
117
+ message=f"Could not retrieve the message with id {reply_to_message_id}.",
118
+ developer_message=(
119
+ f"Could not retrieve the message with id {reply_to_message_id}. "
120
+ f"Reason: '{e.reason}'. Error details: '{e.error_details}'"
121
+ ),
122
+ ) from e
123
+
124
+ replying_to_email = parse_multipart_email(replying_to_email)
125
+
126
+ recipients = build_reply_recipients(
127
+ replying_to_email, current_user["emailAddress"], reply_to_whom
128
+ )
129
+
130
+ email = build_email_message(
131
+ recipient=recipients,
132
+ subject=f"Re: {replying_to_email['subject']}",
133
+ body=body,
134
+ cc=None
135
+ if reply_to_whom == GmailReplyToWhom.ONLY_THE_SENDER
136
+ else replying_to_email["cc"].split(","),
137
+ bcc=bcc,
138
+ replying_to=replying_to_email,
139
+ )
140
+
141
+ sent_message = service.users().messages().send(userId="me", body=email).execute()
142
+
143
+ email = parse_plain_text_email(sent_message)
89
144
  email["url"] = get_sent_email_url(sent_message["id"])
90
145
  return email
91
146
 
@@ -101,28 +156,18 @@ async def write_draft_email(
101
156
  subject: Annotated[str, "The subject of the draft email"],
102
157
  body: Annotated[str, "The body of the draft email"],
103
158
  recipient: Annotated[str, "The recipient of the draft email"],
104
- cc: Annotated[Optional[list[str]], "CC recipients of the draft email"] = None,
105
- bcc: Annotated[Optional[list[str]], "BCC recipients of the draft email"] = None,
159
+ cc: Annotated[list[str] | None, "CC recipients of the draft email"] = None,
160
+ bcc: Annotated[list[str] | None, "BCC recipients of the draft email"] = None,
106
161
  ) -> Annotated[dict, "A dictionary containing the created draft email details"]:
107
162
  """
108
163
  Compose a new email draft using the Gmail API.
109
164
  """
110
165
  # Set up the Gmail API client
111
- service = build("gmail", "v1", credentials=Credentials(context.authorization.token))
112
-
113
- message = MIMEText(body)
114
- message["to"] = recipient
115
- message["subject"] = subject
116
- if cc:
117
- message["Cc"] = ", ".join(cc)
118
- if bcc:
119
- message["Bcc"] = ", ".join(bcc)
120
-
121
- # Encode the message in base64
122
- raw_message = base64.urlsafe_b64encode(message.as_bytes()).decode()
166
+ service = _build_gmail_service(context)
123
167
 
124
- # Create the draft
125
- draft = {"message": {"raw": raw_message}}
168
+ draft = {
169
+ "message": build_email_message(recipient, subject, body, cc, bcc, action=GmailAction.DRAFT)
170
+ }
126
171
 
127
172
  draft_message = service.users().drafts().create(userId="me", body=draft).execute()
128
173
  email = parse_draft_email(draft_message)
@@ -130,6 +175,76 @@ async def write_draft_email(
130
175
  return email
131
176
 
132
177
 
178
+ # Note: in the Gmail UI, a user can customize the recipient and cc fields before replying.
179
+ # We decided not to support this feature, since we'd need a way for LLMs to tell apart between
180
+ # adding or removing recipients/cc, or replacing with an entirely new list of addresses,
181
+ # which would make the tool more complex to call.
182
+ @tool(
183
+ requires_auth=Google(
184
+ scopes=["https://www.googleapis.com/auth/gmail.compose"],
185
+ )
186
+ )
187
+ async def write_draft_reply_email(
188
+ context: ToolContext,
189
+ body: Annotated[str, "The body of the draft reply email"],
190
+ reply_to_message_id: Annotated[str, "The Gmail message ID of the message to draft a reply to"],
191
+ reply_to_whom: Annotated[
192
+ GmailReplyToWhom,
193
+ "Whether to reply to every recipient (including cc) or only to the original sender. "
194
+ f"Defaults to '{GMAIL_DEFAULT_REPLY_TO}'.",
195
+ ] = GMAIL_DEFAULT_REPLY_TO,
196
+ bcc: Annotated[list[str] | None, "BCC recipients of the draft reply email"] = None,
197
+ ) -> Annotated[dict, "A dictionary containing the created draft reply email details"]:
198
+ """
199
+ Compose a draft reply to an email message.
200
+ """
201
+ if isinstance(reply_to_whom, str):
202
+ reply_to_whom = GmailReplyToWhom(reply_to_whom)
203
+
204
+ service = _build_gmail_service(context)
205
+
206
+ current_user = service.users().getProfile(userId="me").execute()
207
+
208
+ try:
209
+ replying_to_email = (
210
+ service.users().messages().get(userId="me", id=reply_to_message_id).execute()
211
+ )
212
+ except HttpError as e:
213
+ raise RetryableToolError(
214
+ message="Could not retrieve the message to respond to.",
215
+ developer_message=(
216
+ "Could not retrieve the message to respond to. "
217
+ f"Reason: '{e.reason}'. Error details: '{e.error_details}'"
218
+ ),
219
+ )
220
+
221
+ replying_to_email = parse_multipart_email(replying_to_email)
222
+
223
+ recipients = build_reply_recipients(
224
+ replying_to_email, current_user["emailAddress"], reply_to_whom
225
+ )
226
+
227
+ draft_message = {
228
+ "message": build_email_message(
229
+ recipient=recipients,
230
+ subject=f"Re: {replying_to_email['subject']}",
231
+ body=body,
232
+ cc=None
233
+ if reply_to_whom == GmailReplyToWhom.ONLY_THE_SENDER
234
+ else replying_to_email["cc"].split(","),
235
+ bcc=bcc,
236
+ replying_to=replying_to_email,
237
+ action=GmailAction.DRAFT,
238
+ ),
239
+ }
240
+
241
+ draft = service.users().drafts().create(userId="me", body=draft_message).execute()
242
+
243
+ email = parse_draft_email(draft)
244
+ email["url"] = get_draft_url(draft["id"])
245
+ return email
246
+
247
+
133
248
  @tool(
134
249
  requires_auth=Google(
135
250
  scopes=["https://www.googleapis.com/auth/gmail.compose"],
@@ -141,15 +256,13 @@ async def update_draft_email(
141
256
  subject: Annotated[str, "The subject of the draft email"],
142
257
  body: Annotated[str, "The body of the draft email"],
143
258
  recipient: Annotated[str, "The recipient of the draft email"],
144
- cc: Annotated[Optional[list[str]], "CC recipients of the draft email"] = None,
145
- bcc: Annotated[Optional[list[str]], "BCC recipients of the draft email"] = None,
259
+ cc: Annotated[list[str] | None, "CC recipients of the draft email"] = None,
260
+ bcc: Annotated[list[str] | None, "BCC recipients of the draft email"] = None,
146
261
  ) -> Annotated[dict, "A dictionary containing the updated draft email details"]:
147
262
  """
148
263
  Update an existing email draft using the Gmail API.
149
264
  """
150
-
151
- # Set up the Gmail API client
152
- service = build("gmail", "v1", credentials=Credentials(context.authorization.token))
265
+ service = _build_gmail_service(context)
153
266
 
154
267
  message = MIMEText(body)
155
268
  message["to"] = recipient
@@ -171,6 +284,7 @@ async def update_draft_email(
171
284
 
172
285
  email = parse_draft_email(updated_draft_message)
173
286
  email["url"] = get_draft_url(updated_draft_message["id"])
287
+
174
288
  return email
175
289
 
176
290
 
@@ -186,9 +300,7 @@ async def delete_draft_email(
186
300
  """
187
301
  Delete a draft email using the Gmail API.
188
302
  """
189
-
190
- # Set up the Gmail API client
191
- service = build("gmail", "v1", credentials=Credentials(context.authorization.token))
303
+ service = _build_gmail_service(context)
192
304
 
193
305
  # Delete the draft
194
306
  service.users().drafts().delete(userId="me", id=draft_email_id).execute()
@@ -208,13 +320,12 @@ async def trash_email(
208
320
  Move an email to the trash folder using the Gmail API.
209
321
  """
210
322
 
211
- # Set up the Gmail API client
212
- service = build("gmail", "v1", credentials=Credentials(context.authorization.token))
323
+ service = _build_gmail_service(context)
213
324
 
214
325
  # Trash the email
215
326
  trashed_email = service.users().messages().trash(userId="me", id=email_id).execute()
216
327
 
217
- email = parse_email(trashed_email)
328
+ email = parse_plain_text_email(trashed_email)
218
329
  email["url"] = get_email_in_trash_url(trashed_email["id"])
219
330
  return email
220
331
 
@@ -232,8 +343,7 @@ async def list_draft_emails(
232
343
  """
233
344
  Lists draft emails in the user's draft mailbox using the Gmail API.
234
345
  """
235
- # Set up the Gmail API client
236
- service = build("gmail", "v1", credentials=Credentials(context.authorization.token))
346
+ service = _build_gmail_service(context)
237
347
 
238
348
  listed_drafts = service.users().drafts().list(userId="me").execute()
239
349
 
@@ -250,12 +360,13 @@ async def list_draft_emails(
250
360
  if draft_details:
251
361
  emails.append(draft_details)
252
362
  except Exception as e:
253
- print(f"Error reading draft email {draft_id}: {e}")
363
+ raise GmailToolError(
364
+ message=f"Error reading draft email {draft_id}.", developer_message=str(e)
365
+ )
254
366
 
255
367
  return {"emails": emails}
256
368
 
257
369
 
258
- # Email Search Tools
259
370
  @tool(
260
371
  requires_auth=Google(
261
372
  scopes=["https://www.googleapis.com/auth/gmail.readonly"],
@@ -263,47 +374,64 @@ async def list_draft_emails(
263
374
  )
264
375
  async def list_emails_by_header(
265
376
  context: ToolContext,
266
- sender: Annotated[Optional[str], "The name or email address of the sender of the email"] = None,
267
- recipient: Annotated[Optional[str], "The name or email address of the recipient"] = None,
268
- subject: Annotated[Optional[str], "Words to find in the subject of the email"] = None,
269
- body: Annotated[Optional[str], "Words to find in the body of the email"] = None,
270
- date_range: Annotated[Optional[DateRange], "The date range of the email"] = None,
271
- limit: Annotated[Optional[int], "The maximum number of emails to return"] = 25,
377
+ sender: Annotated[str | None, "The name or email address of the sender of the email"] = None,
378
+ recipient: Annotated[str | None, "The name or email address of the recipient"] = None,
379
+ subject: Annotated[str | None, "Words to find in the subject of the email"] = None,
380
+ body: Annotated[str | None, "Words to find in the body of the email"] = None,
381
+ date_range: Annotated[DateRange | None, "The date range of the email"] = None,
382
+ label: Annotated[str | None, "The label name to filter by"] = None,
383
+ max_results: Annotated[int, "The maximum number of emails to return"] = 25,
272
384
  ) -> Annotated[
273
385
  dict, "A dictionary containing a list of email details matching the search criteria"
274
386
  ]:
275
387
  """
276
388
  Search for emails by header using the Gmail API.
277
- At least one of the following parameters MUST be provided: sender, recipient, subject, body.
389
+
390
+ At least one of the following parameters MUST be provided: sender, recipient,
391
+ subject, date_range, label, or body.
278
392
  """
279
- if not any([sender, recipient, subject, body]):
393
+ service = _build_gmail_service(context)
394
+ # Ensure at least one search parameter is provided
395
+ if not any([sender, recipient, subject, body, label, date_range]):
280
396
  raise RetryableToolError(
281
- message="At least one of sender, recipient, subject, or body must be provided.",
282
- developer_message="At least one of sender, recipient, subject, or body must be provided.",
397
+ message=(
398
+ "At least one of sender, recipient, subject, body, label, query, "
399
+ "or date_range must be provided."
400
+ ),
401
+ developer_message=(
402
+ "At least one of sender, recipient, subject, body, label, query, "
403
+ "or date_range must be provided."
404
+ ),
283
405
  )
284
406
 
285
- query = build_query_string(sender, recipient, subject, body, date_range)
407
+ # Check if label is valid
408
+ if label:
409
+ label_ids = get_label_ids(service, [label])
410
+
411
+ if not label_ids:
412
+ labels = service.users().labels().list(userId="me").execute().get("labels", [])
413
+ label_names = [label["name"] for label in labels]
414
+ raise RetryableToolError(
415
+ message=f"Invalid label: {label}",
416
+ developer_message=f"Invalid label: {label}",
417
+ additional_prompt_content=f"List of valid labels: {label_names}",
418
+ )
419
+
420
+ # Build a Gmail-style query string based on the filters
421
+ query = build_gmail_query_string(sender, recipient, subject, body, date_range, label)
286
422
 
287
- service = build("gmail", "v1", credentials=Credentials(context.authorization.token))
288
- messages = fetch_messages(service, query, limit)
423
+ # Fetch matching messages. This fetches message metadata from Gmail
424
+ messages = fetch_messages(service, query, max_results)
289
425
 
426
+ # If no messages found, return an empty list
290
427
  if not messages:
291
428
  return {"emails": []}
292
429
 
293
- emails = process_messages(service, messages)
294
- return {"emails": emails}
295
-
430
+ # Process each message into a structured email object
431
+ emails = get_email_details(service, messages)
296
432
 
297
- def process_messages(service, messages):
298
- emails = []
299
- for msg in messages:
300
- try:
301
- email_data = service.users().messages().get(userId="me", id=msg["id"]).execute()
302
- email_details = parse_email(email_data)
303
- emails += [email_details] if email_details else []
304
- except HttpError as e:
305
- print(f"Error reading email {msg['id']}: {e}")
306
- return emails
433
+ # Return the list of emails in a dictionary with key "emails"
434
+ return {"emails": emails}
307
435
 
308
436
 
309
437
  @tool(
@@ -318,8 +446,7 @@ async def list_emails(
318
446
  """
319
447
  Read emails from a Gmail account and extract plain text content.
320
448
  """
321
- # Set up the Gmail API client
322
- service = build("gmail", "v1", credentials=Credentials(context.authorization.token))
449
+ service = _build_gmail_service(context)
323
450
 
324
451
  messages = service.users().messages().list(userId="me").execute().get("messages", [])
325
452
 
@@ -330,12 +457,13 @@ async def list_emails(
330
457
  for msg in messages[:n_emails]:
331
458
  try:
332
459
  email_data = service.users().messages().get(userId="me", id=msg["id"]).execute()
333
- email_details = parse_email(email_data)
460
+ email_details = parse_plain_text_email(email_data)
334
461
  if email_details:
335
462
  emails.append(email_details)
336
463
  except Exception as e:
337
- print(f"Error reading email {msg['id']}: {e}")
338
-
464
+ raise GmailToolError(
465
+ message=f"Error reading email {msg['id']}.", developer_message=str(e)
466
+ )
339
467
  return {"emails": emails}
340
468
 
341
469
 
@@ -347,22 +475,22 @@ async def list_emails(
347
475
  async def search_threads(
348
476
  context: ToolContext,
349
477
  page_token: Annotated[
350
- Optional[str], "Page token to retrieve a specific page of results in the list"
478
+ str | None, "Page token to retrieve a specific page of results in the list"
351
479
  ] = None,
352
480
  max_results: Annotated[int, "The maximum number of threads to return"] = 10,
353
481
  include_spam_trash: Annotated[bool, "Whether to include spam and trash in the results"] = False,
354
- label_ids: Annotated[Optional[list[str]], "The IDs of labels to filter by"] = None,
355
- sender: Annotated[Optional[str], "The name or email address of the sender of the email"] = None,
356
- recipient: Annotated[Optional[str], "The name or email address of the recipient"] = None,
357
- subject: Annotated[Optional[str], "Words to find in the subject of the email"] = None,
358
- body: Annotated[Optional[str], "Words to find in the body of the email"] = None,
359
- date_range: Annotated[Optional[DateRange], "The date range of the email"] = None,
482
+ label_ids: Annotated[list[str] | None, "The IDs of labels to filter by"] = None,
483
+ sender: Annotated[str | None, "The name or email address of the sender of the email"] = None,
484
+ recipient: Annotated[str | None, "The name or email address of the recipient"] = None,
485
+ subject: Annotated[str | None, "Words to find in the subject of the email"] = None,
486
+ body: Annotated[str | None, "Words to find in the body of the email"] = None,
487
+ date_range: Annotated[DateRange | None, "The date range of the email"] = None,
360
488
  ) -> Annotated[dict, "A dictionary containing a list of thread details"]:
361
489
  """Search for threads in the user's mailbox"""
362
- service = build("gmail", "v1", credentials=Credentials(context.authorization.token))
490
+ service = _build_gmail_service(context)
363
491
 
364
492
  query = (
365
- build_query_string(sender, recipient, subject, body, date_range)
493
+ build_gmail_query_string(sender, recipient, subject, body, date_range)
366
494
  if any([sender, recipient, subject, body, date_range])
367
495
  else None
368
496
  )
@@ -377,7 +505,7 @@ async def search_threads(
377
505
  }
378
506
  params = remove_none_values(params)
379
507
 
380
- threads = []
508
+ threads: list[dict[str, Any]] = []
381
509
  next_page_token = None
382
510
  # Paginate through thread pages until we have the desired number of threads
383
511
  while len(threads) < max_results:
@@ -407,13 +535,16 @@ async def search_threads(
407
535
  async def list_threads(
408
536
  context: ToolContext,
409
537
  page_token: Annotated[
410
- Optional[str], "Page token to retrieve a specific page of results in the list"
538
+ str | None, "Page token to retrieve a specific page of results in the list"
411
539
  ] = None,
412
540
  max_results: Annotated[int, "The maximum number of threads to return"] = 10,
413
541
  include_spam_trash: Annotated[bool, "Whether to include spam and trash in the results"] = False,
414
542
  ) -> Annotated[dict, "A dictionary containing a list of thread details"]:
415
543
  """List threads in the user's mailbox."""
416
- return await search_threads(context, page_token, max_results, include_spam_trash)
544
+ threads: dict[str, Any] = await search_threads(
545
+ context, page_token, max_results, include_spam_trash
546
+ )
547
+ return threads
417
548
 
418
549
 
419
550
  @tool(
@@ -424,21 +555,110 @@ async def list_threads(
424
555
  async def get_thread(
425
556
  context: ToolContext,
426
557
  thread_id: Annotated[str, "The ID of the thread to retrieve"],
427
- metadata_headers: Annotated[
428
- Optional[list[str]], "When given and format is METADATA, only include headers specified."
429
- ] = None,
430
558
  ) -> Annotated[dict, "A dictionary containing the thread details"]:
431
559
  """Get the specified thread by ID."""
432
560
  params = {
433
561
  "userId": "me",
434
562
  "id": thread_id,
435
563
  "format": "full",
436
- "metadataHeaders": metadata_headers,
437
564
  }
438
565
  params = remove_none_values(params)
439
566
 
440
- service = build("gmail", "v1", credentials=Credentials(context.authorization.token))
567
+ service = _build_gmail_service(context)
568
+
441
569
  thread = service.users().threads().get(**params).execute()
442
- thread["messages"] = [parse_email(message) for message in thread.get("messages", [])]
570
+ thread["messages"] = [parse_plain_text_email(message) for message in thread.get("messages", [])]
571
+
572
+ return dict(thread)
573
+
574
+
575
+ @tool(
576
+ requires_auth=Google(
577
+ scopes=["https://www.googleapis.com/auth/gmail.modify"],
578
+ )
579
+ )
580
+ async def change_email_labels(
581
+ context: ToolContext,
582
+ email_id: Annotated[str, "The ID of the email to modify labels for"],
583
+ labels_to_add: Annotated[list[str], "List of label names to add"],
584
+ labels_to_remove: Annotated[list[str], "List of label names to remove"],
585
+ ) -> Annotated[dict, "List of labels that were added, removed, and not found"]:
586
+ """
587
+ Add and remove labels from an email using the Gmail API.
588
+ """
589
+ service = _build_gmail_service(context)
590
+
591
+ add_labels = get_label_ids(service, labels_to_add)
592
+ remove_labels = get_label_ids(service, labels_to_remove)
593
+
594
+ invalid_labels = (
595
+ set(labels_to_add + labels_to_remove) - set(add_labels.keys()) - set(remove_labels.keys())
596
+ )
597
+
598
+ if invalid_labels:
599
+ # prepare the list of valid labels
600
+ labels = service.users().labels().list(userId="me").execute().get("labels", [])
601
+ label_names = [label["name"] for label in labels]
602
+
603
+ # raise a retryable error with the list of valid labels
604
+ raise RetryableToolError(
605
+ message=f"Invalid labels: {invalid_labels}",
606
+ developer_message=f"Invalid labels: {invalid_labels}",
607
+ additional_prompt_content=f"List of valid labels: {label_names}",
608
+ )
609
+
610
+ # Prepare the modification body with label IDs.
611
+ body = {
612
+ "addLabelIds": list(add_labels.values()),
613
+ "removeLabelIds": list(remove_labels.values()),
614
+ }
615
+
616
+ try: # Modify the email labels.
617
+ service.users().messages().modify(userId="me", id=email_id, body=body).execute()
618
+
619
+ except Exception as e:
620
+ raise GmailToolError(
621
+ message=f"Error modifying labels for email {email_id}", developer_message=str(e)
622
+ )
623
+
624
+ # Confirmation JSON with lists for added and removed labels.
625
+ confirmation = {
626
+ "addedLabels": list(add_labels.keys()),
627
+ "removedLabels": list(remove_labels.keys()),
628
+ }
629
+
630
+ return {"confirmation": dict(confirmation)}
631
+
632
+
633
+ @tool(
634
+ requires_auth=Google(
635
+ scopes=["https://www.googleapis.com/auth/gmail.readonly"],
636
+ )
637
+ )
638
+ async def list_labels(
639
+ context: ToolContext,
640
+ ) -> Annotated[dict, "A dictionary containing a list of label details"]:
641
+ """List all the labels in the user's mailbox."""
642
+
643
+ service = _build_gmail_service(context)
644
+
645
+ labels = service.users().labels().list(userId="me").execute().get("labels", [])
646
+
647
+ return {"labels": labels}
648
+
649
+
650
+ @tool(
651
+ requires_auth=Google(
652
+ scopes=["https://www.googleapis.com/auth/gmail.labels"],
653
+ )
654
+ )
655
+ async def create_label(
656
+ context: ToolContext,
657
+ label_name: Annotated[str, "The name of the label to create"],
658
+ ) -> Annotated[dict, "The details of the created label"]:
659
+ """Create a new label in the user's mailbox."""
660
+
661
+ service = _build_gmail_service(context)
662
+ label = service.users().labels().create(userId="me", body={"name": label_name}).execute()
443
663
 
444
- return thread
664
+ return {"label": label}